Синхронизация форм с серверными данными

Формы в клиентских приложениях работают с локальным состоянием интерфейса, тогда как TanStack Query управляет серверным состоянием. Эти два типа данных имеют принципиально разную природу:

  • локальное состояние формы изменяется мгновенно;
  • серверные данные приходят асинхронно;
  • сервер может обновлять информацию независимо от пользователя;
  • форма может содержать незавершённые изменения;
  • данные могут инвалидироваться и повторно загружаться.

Из-за этого появляются типичные проблемы:

  • потеря пользовательского ввода после refetch;
  • отображение устаревших значений;
  • конфликты между cache и form state;
  • двойные источники истины;
  • рассинхронизация optimistic update и формы;
  • сброс dirty-полей после обновления query.

TanStack Query не предназначен для хранения form state. Его задача — управление серверными данными. Поэтому форма почти всегда должна иметь собственное локальное состояние.


Разделение server state и form state

Ключевая архитектурная идея:

  • TanStack Query хранит серверные данные;
  • форма хранит пользовательские изменения отдельно.

Неправильный подход:

const { data } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
})

<input
    value={data.name}
    onCha nge={(e) => {
        data.name = e.target.value
    }}
/>

Проблемы такого решения:

  • mutation объекта cache;
  • отсутствие реактивности;
  • нарушение принципов immutable state;
  • cache TanStack Query становится непредсказуемым.

Правильная схема:

const { data } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
})

const [form, setForm] = useState({
    name: '',
    email: ''
})

Серверные данные используются только как источник начальных значений.


Инициализация формы из query

Наиболее распространённый сценарий — загрузка сущности и заполнение формы.

const { data, isLoading } = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile
})

const [form, setForm] = useState({
    firstName: '',
    lastName: ''
})

useEffect(() => {
    if (data) {
        setForm({
            firstName: data.firstName,
            lastName: data.lastName
        })
    }
}, [data])

Такой подход безопасен только при первичной инициализации. Если query обновится повторно, форма будет перезаписана.


Проблема повторной синхронизации

Предположим:

  1. пользователь начал редактировать форму;
  2. query автоматически refetch-нулась;
  3. effect снова выполнил setForm;
  4. пользовательский ввод потерян.

Это одна из самых частых ошибок.

Плохой пример:

useEffect(() => {
    if (data) {
        setForm(data)
    }
}, [data])

Если включены:

  • refetchOnWindowFocus
  • polling
  • invalidation
  • reconnect refetch

то форма может сбрасываться постоянно.


Однократная инициализация формы

Обычно требуется заполнить форму только один раз.

Использование флага инициализации

const initializedRef = useRef(false)

useEffect(() => {
    if (data && !initializedRef.current) {
        setForm(data)
        initializedRef.current = true
    }
}, [data])

Теперь refetch не уничтожит пользовательские изменения.


Инициализация через defaultValues

При использовании React Hook Form проблема решается иначе.

const { data } = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile
})

const form = useForm({
    defaultValues: data
})

Но здесь возникает проблема:

defaultValues

используются только при первом рендере.

Если query ещё не загрузилась, форма получит undefined.


Синхронизация React Hook Form и Query

Правильный способ:

const { data } = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile
})

const form = useForm({
    defaultValues: {
        firstName: '',
        lastName: ''
    }
})

useEffect(() => {
    if (data) {
        form.reset(data)
    }
}, [data, form])

Но это снова может сбрасывать пользовательский ввод после refetch.


Безопасный reset формы

Нужно учитывать dirty state.

useEffect(() => {
    if (data && !form.formState.isDirty) {
        form.reset(data)
    }
}, [data, form])

Теперь серверные данные обновят форму только если пользователь ещё ничего не менял.


Частичная синхронизация полей

Иногда нужно обновлять только untouched-поля.

Например:

  • сервер обновил email;
  • пользователь редактирует phone;
  • email должен обновиться автоматически;
  • phone трогать нельзя.

Пример:

const values = form.getValues()

if (!form.formState.dirtyFields.email) {
    form.setValue('email', data.email)
}

Это особенно важно в:

  • CRM;
  • административных панелях;
  • совместном редактировании;
  • real-time приложениях.

Работа с background refetch

TanStack Query автоматически выполняет background refetch.

Например:

const query = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    refetchOnWindowFocus: true
})

При возвращении во вкладку данные обновятся.

Если форма синхронизируется неправильно, произойдёт:

  • reset полей;
  • потеря cursor position;
  • мерцание интерфейса;
  • сброс validation state.

Отключение автоматического refetch

Иногда форма должна быть полностью изолирована.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
    staleTime: Infinity
})

Такой подход полезен для:

  • длинных wizard-форм;
  • многошаговых редакторов;
  • сложных CMS;
  • конфигураторов.

Обновление данных после submit

После сохранения формы серверное состояние должно синхронизироваться с cache.

Стандартный сценарий:

const mutation = useMutation({
    mutationFn: updateProfile,
    onSuccess: () => {
        queryClient.invalidateQueries({
            queryKey: ['profile']
        })
    }
})

После invalidate:

  1. query refetch-ится;
  2. cache обновляется;
  3. UI получает актуальные данные.

Избежание лишнего refetch после submit

Если mutation уже возвращает обновлённую сущность, invalidate не нужен.

const mutation = useMutation({
    mutationFn: updateProfile,
    onSuccess: (updatedProfile) => {
        queryClient.setQueryData(
            ['profile'],
            updatedProfile
        )
    }
})

Преимущества:

  • отсутствие дополнительного HTTP-запроса;
  • мгновенное обновление UI;
  • меньше нагрузки на сервер.

Синхронизация формы после mutation

После успешного сохранения локальная форма тоже должна обновиться.

onSuccess: (updatedProfile) => {
    queryClient.setQueryData(
        ['profile'],
        updatedProfile
    )

    form.reset(updatedProfile)
}

Это важно, потому что:

  • dirty state сбрасывается;
  • форма становится синхронной с сервером;
  • validation пересчитывается корректно.

Optimistic update и формы

Optimistic update особенно сложны вместе с form state.

Пример:

const mutation = useMutation({
    mutationFn: updateProfile,

    onMutate: async (newData) => {
        await queryClient.cancelQueries({
            queryKey: ['profile']
        })

        const previous =
            queryClient.getQueryData(['profile'])

        queryClient.setQueryData(
            ['profile'],
            old => ({
                ...old,
                ...newData
            })
        )

        return { previous }
    },

    onError: (err, variables, context) => {
        queryClient.setQueryData(
            ['profile'],
            context.previous
        )
    }
})

Если форма хранит отдельное состояние, необходимо синхронизировать rollback.


Конфликт optimistic update и form state

Сценарий:

  1. форма локально изменилась;
  2. optimistic update обновил cache;
  3. сервер вернул ошибку;
  4. cache откатился;
  5. форма осталась в новом состоянии.

Возникает рассинхронизация.

Решение:

onError: (err, variables, context) => {
    queryClient.setQueryData(
        ['profile'],
        context.previous
    )

    form.reset(context.previous)
}

Использование select для подготовки формы

Часто серверные данные неудобны для формы.

Например:

{
    "first_name": "John",
    "last_name": "Doe"
}

Форма ожидает:

{
    firstName: '',
    lastName: ''
}

Использование select:

const query = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,

    select: (data) => ({
        firstName: data.first_name,
        lastName: data.last_name
    })
})

Преимущества:

  • UI получает уже подготовленные данные;
  • отсутствует mapping в компоненте;
  • cache остаётся нормализованным.

Преобразование формы перед mutation

Обратное преобразование:

mutation.mutate({
    first_name: form.firstName,
    last_name: form.lastName
})

Лучше вынести mapper отдельно.

function mapProfileToApi(form) {
    return {
        first_name: form.firstName,
        last_name: form.lastName
    }
}

Сохранение draft-данных

Иногда пользователь может закрыть страницу до submit.

Подход:

  • query хранит серверную версию;
  • localStorage хранит draft;
  • форма объединяет оба источника.

Пример:

const serverData = query.data

const draft =
    JSON.parse(localStorage.getItem('draft'))

const initialData = {
    ...serverData,
    ...draft
}

Теперь локальный черновик имеет приоритет.


Автосохранение формы

TanStack Query хорошо подходит для autosave.

Пример debounce-сохранения:

useEffect(() => {
    const timeout = setTimeout(() => {
        mutation.mutate(form)
    }, 1000)

    return () => clearTimeout(timeout)
}, [form])

Но без защиты возможны race condition.


Race condition при autosave

Проблемный сценарий:

  1. запрос A отправлен;
  2. запрос B отправлен позже;
  3. ответ B пришёл первым;
  4. ответ A пришёл последним;
  5. сервер содержит старые данные.

Очередь autosave-запросов

Можно сериализовать запросы.

const saveQueue = useRef(Promise.resolve())

function enqueueSave(data) {
    saveQueue.current =
        saveQueue.current.then(() =>
            mutation.mutateAsync(data)
        )
}

Теперь запросы выполняются последовательно.


Отмена запросов при вводе

Другой подход — отмена предыдущих запросов.

const controllerRef = useRef()

async function save(data) {
    controllerRef.current?.abort()

    controllerRef.current =
        new AbortController()

    await fetch('/profile', {
        method: 'POST',
        signal: controllerRef.current.signal,
        body: JSON.stringify(data)
    })
}

Синхронизация nested-форм

Сложные формы часто содержат:

  • массивы;
  • вложенные объекты;
  • динамические поля.

Пример:

{
    profile: {
        contacts: {
            phone: '',
            email: ''
        }
    }
}

При частичном обновлении необходимо избегать полного replace объекта.

Плохо:

setForm(data)

Лучше:

setForm(prev => ({
    ...prev,
    profile: {
        ...prev.profile,
        contacts: {
            ...prev.profile.contacts,
            email: data.profile.contacts.email
        }
    }
}))

Формы и pagination cache

Если форма редактирует элемент списка:

['users', page]

то после mutation необходимо синхронизировать:

  • query детали;
  • paginated query;
  • infinite query;
  • search query.

Пример:

queryClient.setQueriesData(
    {
        queryKey: ['users']
    },
    old => {
        return {
            ...old,
            items: old.items.map(user =>
                user.id === updated.id
                    ? updated
                    : user
            )
        }
    }
)

Формы и infinite query

Infinite queries имеют более сложную структуру.

queryClient.setQueryData(
    ['users'],
    old => ({
        ...old,

        pages: old.pages.map(page => ({
            ...page,

            items: page.items.map(item =>
                item.id === updated.id
                    ? updated
                    : item
            )
        }))
    })
)

Сброс формы после invalidate

Иногда invalidate приходит извне:

  • websocket;
  • mutation другого пользователя;
  • background sync.

В таких случаях нужно определить стратегию:

Полный reset

form.reset(data)

Игнорирование обновления

if (form.formState.isDirty) {
    return
}

Merge untouched-полей

if (!dirtyFields.name) {
    setValue('name', data.name)
}

Использование derived state

Иногда локальное состояние формы вообще не требуется.

Пример фильтров:

const [search, setSearch] = useState('')

const query = useQuery({
    queryKey: ['users', search],
    queryFn: () => fetchUsers(search)
})

Здесь input напрямую влияет на query key.


Синхронизация query params и формы

Форма фильтрации часто синхронизируется с URL.

const [params, setParams] =
    useSearchParams()

const status =
    params.get('status') || 'all'

Изменение формы:

setParams({
    status: value
})

Query автоматически обновится:

useQuery({
    queryKey: ['orders', status],
    queryFn: () => fetchOrders(status)
})

Серверная валидация форм

Mutation может вернуть validation errors.

Пример ответа:

{
    "errors": {
        "email": "Email already exists"
    }
}

Интеграция с React Hook Form:

onError: (error) => {
    Object.entries(error.errors)
        .forEach(([field, message]) => {
            form.setError(field, {
                type: 'server',
                message
            })
        })
}

Синхронизация loading state

Форма должна учитывать состояние mutation.

<button disabled={mutation.isPending}>
    Save
</button>

Для предотвращения дублирующих submit:

if (mutation.isPending) {
    return
}

Глобальное состояние mutation

TanStack Query позволяет отслеживать активные mutation.

const isSaving = useIsMutating({
    mutationKey: ['profile-update']
}) > 0

Это полезно для:

  • глобального индикатора сохранения;
  • блокировки навигации;
  • autosave UI;
  • состояния “Saving…”.

Синхронизация нескольких форм

Несколько форм могут редактировать один cache.

Например:

  • sidebar profile editor;
  • modal editor;
  • main page form.

Если одна форма сохраняет данные:

queryClient.setQueryData(
    ['profile'],
    updated
)

все query автоматически обновятся.

Но локальные form state останутся прежними.


Централизованная стратегия обновления форм

Часто используется подход:

  • query — единственный источник серверной истины;
  • формы подписываются на cache;
  • dirty fields защищаются локально;
  • после submit выполняется reset.

Такая архитектура обеспечивает:

  • предсказуемость;
  • отсутствие гонок;
  • стабильный UX;
  • корректную работу background sync;
  • безопасную интеграцию optimistic update.