Формы в клиентских приложениях работают с локальным состоянием интерфейса, тогда как TanStack Query управляет серверным состоянием. Эти два типа данных имеют принципиально разную природу:
Из-за этого появляются типичные проблемы:
TanStack Query не предназначен для хранения form state. Его задача — управление серверными данными. Поэтому форма почти всегда должна иметь собственное локальное состояние.
Ключевая архитектурная идея:
Неправильный подход:
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser
})
<input
value={data.name}
onCha nge={(e) => {
data.name = e.target.value
}}
/>
Проблемы такого решения:
Правильная схема:
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser
})
const [form, setForm] = useState({
name: '',
email: ''
})
Серверные данные используются только как источник начальных значений.
Наиболее распространённый сценарий — загрузка сущности и заполнение формы.
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 обновится повторно, форма будет перезаписана.
Предположим:
setForm;Это одна из самых частых ошибок.
Плохой пример:
useEffect(() => {
if (data) {
setForm(data)
}
}, [data])
Если включены:
refetchOnWindowFocusто форма может сбрасываться постоянно.
Обычно требуется заполнить форму только один раз.
const initializedRef = useRef(false)
useEffect(() => {
if (data && !initializedRef.current) {
setForm(data)
initializedRef.current = true
}
}, [data])
Теперь refetch не уничтожит пользовательские изменения.
При использовании React Hook Form проблема решается иначе.
const { data } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile
})
const form = useForm({
defaultValues: data
})
Но здесь возникает проблема:
defaultValues
используются только при первом рендере.
Если query ещё не загрузилась, форма получит
undefined.
Правильный способ:
const { data } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile
})
const form = useForm({
defaultValues: {
firstName: '',
lastName: ''
}
})
useEffect(() => {
if (data) {
form.reset(data)
}
}, [data, form])
Но это снова может сбрасывать пользовательский ввод после refetch.
Нужно учитывать dirty state.
useEffect(() => {
if (data && !form.formState.isDirty) {
form.reset(data)
}
}, [data, form])
Теперь серверные данные обновят форму только если пользователь ещё ничего не менял.
Иногда нужно обновлять только untouched-поля.
Например:
Пример:
const values = form.getValues()
if (!form.formState.dirtyFields.email) {
form.setValue('email', data.email)
}
Это особенно важно в:
TanStack Query автоматически выполняет background refetch.
Например:
const query = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
refetchOnWindowFocus: true
})
При возвращении во вкладку данные обновятся.
Если форма синхронизируется неправильно, произойдёт:
Иногда форма должна быть полностью изолирована.
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity
})
Такой подход полезен для:
После сохранения формы серверное состояние должно синхронизироваться с cache.
Стандартный сценарий:
const mutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['profile']
})
}
})
После invalidate:
Если mutation уже возвращает обновлённую сущность, invalidate не нужен.
const mutation = useMutation({
mutationFn: updateProfile,
onSuccess: (updatedProfile) => {
queryClient.setQueryData(
['profile'],
updatedProfile
)
}
})
Преимущества:
После успешного сохранения локальная форма тоже должна обновиться.
onSuccess: (updatedProfile) => {
queryClient.setQueryData(
['profile'],
updatedProfile
)
form.reset(updatedProfile)
}
Это важно, потому что:
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.
Сценарий:
Возникает рассинхронизация.
Решение:
onError: (err, variables, context) => {
queryClient.setQueryData(
['profile'],
context.previous
)
form.reset(context.previous)
}
Часто серверные данные неудобны для формы.
Например:
{
"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
})
})
Преимущества:
Обратное преобразование:
mutation.mutate({
first_name: form.firstName,
last_name: form.lastName
})
Лучше вынести mapper отдельно.
function mapProfileToApi(form) {
return {
first_name: form.firstName,
last_name: form.lastName
}
}
Иногда пользователь может закрыть страницу до submit.
Подход:
Пример:
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.
Проблемный сценарий:
Можно сериализовать запросы.
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)
})
}
Сложные формы часто содержат:
Пример:
{
profile: {
contacts: {
phone: '',
email: ''
}
}
}
При частичном обновлении необходимо избегать полного replace объекта.
Плохо:
setForm(data)
Лучше:
setForm(prev => ({
...prev,
profile: {
...prev.profile,
contacts: {
...prev.profile.contacts,
email: data.profile.contacts.email
}
}
}))
Если форма редактирует элемент списка:
['users', page]
то после mutation необходимо синхронизировать:
Пример:
queryClient.setQueriesData(
{
queryKey: ['users']
},
old => {
return {
...old,
items: old.items.map(user =>
user.id === updated.id
? updated
: user
)
}
}
)
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 приходит извне:
В таких случаях нужно определить стратегию:
form.reset(data)
if (form.formState.isDirty) {
return
}
if (!dirtyFields.name) {
setValue('name', data.name)
}
Иногда локальное состояние формы вообще не требуется.
Пример фильтров:
const [search, setSearch] = useState('')
const query = useQuery({
queryKey: ['users', search],
queryFn: () => fetchUsers(search)
})
Здесь input напрямую влияет на query key.
Форма фильтрации часто синхронизируется с 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
})
})
}
Форма должна учитывать состояние mutation.
<button disabled={mutation.isPending}>
Save
</button>
Для предотвращения дублирующих submit:
if (mutation.isPending) {
return
}
TanStack Query позволяет отслеживать активные mutation.
const isSaving = useIsMutating({
mutationKey: ['profile-update']
}) > 0
Это полезно для:
Несколько форм могут редактировать один cache.
Например:
Если одна форма сохраняет данные:
queryClient.setQueryData(
['profile'],
updated
)
все query автоматически обновятся.
Но локальные form state останутся прежними.
Часто используется подход:
Такая архитектура обеспечивает: