Автосохранение в приложениях — механизм автоматической отправки изменённых данных на сервер без явного действия пользователя. В интерфейсах редактирования профиля, настроек, заметок, документов, комментариев и административных панелей такой подход позволяет:
В контексте TanStack Query автосохранение обычно строится вокруг:
useMutationНаиболее простой сценарий:
import { useState, useEffect } from 'react'
import { useMutation } from '@tanstack/react-query'
async function saveProfile(data) {
const response = await fetch('/api/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Ошибка сохранения')
}
return response.json()
}
export function ProfileForm() {
const [form, setForm] = useState({
name: '',
about: ''
})
const mutation = useMutation({
mutationFn: saveProfile
})
useEffect(() => {
const timer = setTimeout(() => {
mutation.mutate(form)
}, 1000)
return () => clearTimeout(timer)
}, [form])
return (
<div>
<input
value={form.name}
onCha nge={(e) =>
setForm({
...form,
name: e.target.value
})
}
/>
<textarea
value={form.about}
onCha nge={(e) =>
setForm({
...form,
about: e.target.value
})
}
/>
</div>
)
}
Подобная реализация имеет множество недостатков.
Каждое изменение создаёт новый таймер и новый запрос.
При быстром вводе текста сервер может получить десятки mutation подряд.
Если старый запрос завершится позже нового, устаревшие данные могут перезаписать свежие.
Не отображается:
Постоянные PATCH-запросы увеличивают нагрузку:
Самый распространённый подход — debounce.
Изменения отправляются только после паузы ввода.
import { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
async function updateDocument(data) {
const response = await fetch('/api/document', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
return response.json()
}
export function Editor() {
const [content, setContent] = useState('')
const mutation = useMutation({
mutationFn: updateDocument
})
useEffect(() => {
if (!content.trim()) {
return
}
const timeout = setTimeout(() => {
mutation.mutate({
content
})
}, 800)
return () => clearTimeout(timeout)
}, [content])
return (
<textarea
value={content}
onCha nge={(e) => setContent(e.target.value)}
/>
)
}
Интерфейс должен показывать текущее состояние сохранения.
| Состояние | Назначение |
|---|---|
| idle | изменений нет |
| pending | выполняется сохранение |
| success | данные сохранены |
| error | ошибка сохранения |
function SaveStatus({ mutation }) {
if (mutation.isPending) {
return <p>Сохранение...</p>
}
if (mutation.isError) {
return <p>Ошибка сохранения</p>
}
if (mutation.isSuccess) {
return <p>Сохранено</p>
}
return null
}
Рассмотрим ситуацию:
Подобная проблема особенно критична:
Один из способов — блокировать новый запрос, пока предыдущий не завершён.
if (!mutation.isPending) {
mutation.mutate(data)
}
Однако такой подход приводит к потере части изменений.
Более надёжный вариант — очередь изменений.
const queueRef = useRef(Promise.resolve())
function enqueueSave(data) {
queueRef.current = queueRef.current.then(() => {
return mutation.mutateAsync(data)
})
}
Теперь каждый запрос выполняется строго после предыдущего.
Иногда промежуточные изменения не нужны.
Например:
В таком случае достаточно сохранить только финальное состояние.
const latestDataRef = useRef(null)
useEffect(() => {
latestDataRef.current = form
const timeout = setTimeout(() => {
mutation.mutate(latestDataRef.current)
}, 1000)
return () => clearTimeout(timeout)
}, [form])
Для автосохранения чаще всего используются:
mutationFnonMutateonSuccessonErroronSettledАвтосохранение должно выглядеть мгновенным.
Для этого применяется optimistic update.
const queryClient = useQueryClient()
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: (error, variables, context) => {
queryClient.setQueryData(
['profile'],
context.previous
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['profile']
})
}
})
При автосохранении старые запросы могут становиться неактуальными.
TanStack Query поддерживает отмену запросов через AbortController.
async function saveSettings(data, signal) {
const response = await fetch('/api/settings', {
method: 'PATCH',
signal,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
return response.json()
}
Форма должна понимать:
const [savedData, setSavedData] = useState(initialData)
const [formData, setFormData] = useState(initialData)
const isDirty =
JSON.stringify(savedData) !==
JSON.stringify(formData)
Если данные ещё не сохранены, приложение может предупредить пользователя.
useEffect(() => {
const beforeUnload = (event) => {
if (!isDirty) {
return
}
event.preventDefault()
event.returnValue = ''
}
window.addEventListener(
'beforeunload',
beforeUnload
)
return () => {
window.removeEventListener(
'beforeunload',
beforeUnload
)
}
}, [isDirty])
TanStack Query часто используется совместно с React Hook Form.
const form = useForm()
const values = form.watch()
useEffect(() => {
const timeout = setTimeout(() => {
mutation.mutate(values)
}, 700)
return () => clearTimeout(timeout)
}, [values])
watch() может вызывать большое количество
ререндеров.
Более эффективный вариант — useWatch.
const values = useWatch({
control: form.control
})
Иногда необходимо сохранять только изменённое поле.
function updateField(name, value) {
mutation.mutate({
[name]: value
})
}
Редакторы имеют дополнительные сложности:
Часто применяются:
Вместо полного документа можно отправлять только изменения.
{
"operations": [
{
"type": "replace",
"path": "/title",
"value": "Новый заголовок"
}
]
}
Изменения накапливаются и отправляются пачкой.
const changesRef = useRef([])
function addChange(change) {
changesRef.current.push(change)
}
useEffect(() => {
const timeout = setTimeout(() => {
mutation.mutate(changesRef.current)
changesRef.current = []
}, 2000)
return () => clearTimeout(timeout)
}, [])
TanStack Query поддерживает offline-first подход.
Mutation могут:
const mutation = useMutation({
mutationFn: saveDraft,
retry: 3,
retryDelay: 2000
})
Автоматический retry полезен:
Но опасен:
Практически все редакторы отображают статус:
import {
useEffect,
useRef,
useState
} from 'react'
import {
useMutation
} from '@tanstack/react-query'
async function saveDraft(data) {
const response = await fetch('/api/draft', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Ошибка')
}
return response.json()
}
export function DraftEditor() {
const [text, setText] = useState('')
const [savedText, setSavedText] = useState('')
const timeoutRef = useRef(null)
const mutation = useMutation({
mutationFn: saveDraft,
onSuccess: (_, variables) => {
setSavedText(variables.text)
}
})
useEffect(() => {
clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (text === savedText) {
return
}
mutation.mutate({
text
})
}, 1000)
return () => {
clearTimeout(timeoutRef.current)
}
}, [text, savedText])
const isDirty = text !== savedText
return (
<div>
<textarea
value={text}
onCha nge={(e) =>
setText(e.target.value)
}
/>
{mutation.isPending && (
<p>Сохранение...</p>
)}
{!mutation.isPending && isDirty && (
<p>Есть несохранённые изменения</p>
)}
{!mutation.isPending && !isDirty && (
<p>Сохранено</p>
)}
{mutation.isError && (
<p>Ошибка сохранения</p>
)}
</div>
)
}
Если документ редактируется одновременно несколькими пользователями, автосохранение усложняется.
Появляются проблемы:
Последнее изменение побеждает.
Самый простой, но не всегда безопасный вариант.
Сервер хранит номер версии документа.
{
"id": 10,
"version": 15,
"content": "Текст"
}
При сохранении проверяется актуальность версии.
Используется в совместных редакторах:
Изменения преобразуются относительно других операций.
После успешного сохранения часто требуется обновление связанных данных.
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['documents']
})
}
При автосохранении слишком частая инвалидизация может:
В таких случаях предпочтительнее:
setQueryData;На производительность влияют:
Подходит:
Необходимы:
Особенно важны:
Создаёт чрезмерную нагрузку.
Может вызвать бесконечные циклы refetch.
Приводит к сотням запросов.
Вызывает потерю пользовательских данных.
Неэффективно при частых изменениях.
Крупные приложения обычно разделяют:
Такой подход позволяет: