Урок 04

Работа с формами и API

Форма → валидация → отправка → обработка ответа. Полный цикл.

Состояние формы

type FormValues = {
    title: string;
    body: string;
};

const [values, setValues] = useState<FormValues>({ title: '', body: '' });
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
const [submitting, setSubmitting] = useState(false);

Валидация

function validate(values: FormValues) {
    const errors: Partial<Record<keyof FormValues, string>> = {};

    if (!values.title.trim()) errors.title = 'Заголовок обязателен';
    if (values.body.length < 10) errors.body = 'Минимум 10 символов';

    return errors;
}

Отправка

const mutation = useMutation({
    mutationFn: (data: FormValues) => api.post('/posts', data),
    onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['posts'] });
        navigation.goBack();
    },
    onError: (error) => {
        if (axios.isAxiosError(error) && error.response?.status === 422) {
            setErrors(error.response.data.errors);
        }
    },
});

function handleSubmit() {
    const validationErrors = validate(values);
    setErrors(validationErrors);

    if (Object.keys(validationErrors).length > 0) return;

    mutation.mutate(values);
}

Форма с полями

<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
    <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16 }}>
        <TextInput
            value={values.title}
            onChangeText={(v) => setValues((s) => ({ ...s, title: v }))}
            placeholder="Заголовок"
            style={[styles.input, errors.title && styles.inputError]}
        />
        {errors.title ? <Text style={styles.error}>{errors.title}</Text> : null}

        <TextInput
            value={values.body}
            onChangeText={(v) => setValues((s) => ({ ...s, body: v }))}
            placeholder="Текст"
            multiline
            numberOfLines={6}
            style={[styles.input, styles.textarea, errors.body && styles.inputError]}
        />
        {errors.body ? <Text style={styles.error}>{errors.body}</Text> : null}

        <Pressable
            onPress={handleSubmit}
            disabled={mutation.isPending}
            style={[styles.button, mutation.isPending && styles.disabled]}
        >
            {mutation.isPending ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Сохранить</Text>}
        </Pressable>
    </ScrollView>
</KeyboardAvoidingView>

React Hook Form

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
    title: z.string().min(1, 'Заголовок обязателен'),
    body: z.string().min(10, 'Минимум 10 символов'),
});

type FormValues = z.infer<typeof schema>;

function Form() {
    const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormValues>({
        resolver: zodResolver(schema),
        defaultValues: { title: '', body: '' },
    });

    const mutation = useMutation({
        mutationFn: (data: FormValues) => api.post('/posts', data),
    });

    const onSubmit = (data: FormValues) => mutation.mutateAsync(data);

    return (
        <ScrollView>
            <Controller
                control={control}
                name="title"
                render={({ field }) => (
                    <TextInput
                        value={field.value}
                        onChangeText={field.onChange}
                        onBlur={field.onBlur}
                        placeholder="Заголовок"
                    />
                )}
            />
            {errors.title ? <Text style={styles.error}>{errors.title.message}</Text> : null}

            <Pressable onPress={handleSubmit(onSubmit)} disabled={isSubmitting}>
                <Text>Сохранить</Text>
            </Pressable>
        </ScrollView>
    );
}

Работа с ошибками сервера

const mutation = useMutation({
    mutationFn: (data: FormValues) => api.post('/posts', data),
    onError: (error) => {
        if (axios.isAxiosError(error) && error.response?.status === 422) {
            const serverErrors = error.response.data.errors;
            Object.entries(serverErrors).forEach(([field, messages]) => {
                setError(field as keyof FormValues, { message: (messages as string[])[0] });
            });
        }
    },
});

Успех и переход

onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
    Toast.show({ type: 'success', text1: 'Сохранено' });
    navigation.goBack();
}

Итоги

  • Состояние формы + ошибки
  • React Hook Form + Zod
  • Controller для TextInput
  • Обработка 422 от сервера
  • mutation.isPending для индикатора
  • Toast и навигация после успеха