Автосохранение

Автосохранение в приложениях — механизм автоматической отправки изменённых данных на сервер без явного действия пользователя. В интерфейсах редактирования профиля, настроек, заметок, документов, комментариев и административных панелей такой подход позволяет:

  • уменьшить риск потери данных;
  • сократить количество кнопок и действий;
  • обеспечить постоянную синхронизацию состояния;
  • улучшить UX при длительном редактировании.

В контексте TanStack Query автосохранение обычно строится вокруг:

  • useMutation
  • отложенной отправки изменений
  • контроля конкурентных запросов
  • оптимистичных обновлений
  • отмены устаревших операций
  • синхронизации кеша

Базовая схема автосохранения

Наиболее простой сценарий:

  1. Пользователь изменяет данные формы.
  2. Изменение попадает в локальное состояние.
  3. Через некоторое время запускается mutation.
  4. Сервер сохраняет данные.
  5. Кеш обновляется.

Пример простой реализации

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-запросы увеличивают нагрузку:

  • на API;
  • на сеть;
  • на базу данных.

Debounce при автосохранении

Самый распространённый подход — debounce.

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


Использование 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
}

Предотвращение гонок запросов

Рассмотрим ситуацию:

  1. Отправлен запрос A.
  2. Пользователь быстро изменил данные.
  3. Отправлен запрос B.
  4. Запрос B завершился раньше A.
  5. Запрос A перезаписал более новые данные.

Подобная проблема особенно критична:

  • в редакторах;
  • CRM;
  • административных системах;
  • совместном редактировании.

Последовательное выполнение mutation

Один из способов — блокировать новый запрос, пока предыдущий не завершён.

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])

useMutation и автосохранение

Для автосохранения чаще всего используются:

  • mutationFn
  • onMutate
  • onSuccess
  • onError
  • onSettled

Оптимистичное обновление

Автосохранение должно выглядеть мгновенным.

Для этого применяется 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.


Mutation с поддержкой abort

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()
}

Контроль dirty-состояния

Форма должна понимать:

  • есть ли несохранённые изменения;
  • завершено ли сохранение;
  • совпадают ли данные с сервером.

Пример dirty-check

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])

Автосохранение и React Hook Form

TanStack Query часто используется совместно с React Hook Form.


Интеграция с watch

const form = useForm()

const values = form.watch()

useEffect(() => {
  const timeout = setTimeout(() => {
    mutation.mutate(values)
  }, 700)

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

Избежание лишних mutation

watch() может вызывать большое количество ререндеров.

Более эффективный вариант — useWatch.

const values = useWatch({
  control: form.control
})

Разделение автосохранения по полям

Иногда необходимо сохранять только изменённое поле.

Пример

function updateField(name, value) {
  mutation.mutate({
    [name]: value
  })
}

Автосохранение текстовых редакторов

Редакторы имеют дополнительные сложности:

  • очень частые изменения;
  • большой объём данных;
  • коллизии;
  • синхронизация версий.

Минимизация количества запросов

Часто применяются:

  • debounce;
  • batch updates;
  • diff-сохранение;
  • сохранение только изменённых блоков.

Сохранение diff

Вместо полного документа можно отправлять только изменения.

{
  "operations": [
    {
      "type": "replace",
      "path": "/title",
      "value": "Новый заголовок"
    }
  ]
}

Batch-сохранение

Изменения накапливаются и отправляются пачкой.

const changesRef = useRef([])

function addChange(change) {
  changesRef.current.push(change)
}

useEffect(() => {
  const timeout = setTimeout(() => {
    mutation.mutate(changesRef.current)

    changesRef.current = []
  }, 2000)

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

Автосохранение с offline-поддержкой

TanStack Query поддерживает offline-first подход.

Mutation могут:

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

Retry при автосохранении

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>
  )
}

Проблема конфликтов данных

Если документ редактируется одновременно несколькими пользователями, автосохранение усложняется.

Появляются проблемы:

  • перезаписи данных;
  • конфликтов версий;
  • рассинхронизации интерфейса.

Подходы к разрешению конфликтов

Last Write Wins

Последнее изменение побеждает.

Самый простой, но не всегда безопасный вариант.


Versioning

Сервер хранит номер версии документа.

{
  "id": 10,
  "version": 15,
  "content": "Текст"
}

При сохранении проверяется актуальность версии.


Operational Transform

Используется в совместных редакторах:

  • Google Docs;
  • Notion;
  • Figma.

Изменения преобразуются относительно других операций.


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

После успешного сохранения часто требуется обновление связанных данных.

onSuccess: () => {
  queryClient.invalidateQueries({
    queryKey: ['documents']
  })
}

Когда invalidateQueries нежелателен

При автосохранении слишком частая инвалидизация может:

  • перегружать API;
  • вызывать лишние refetch;
  • создавать мерцание интерфейса.

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

  • setQueryData;
  • оптимистичное обновление;
  • локальная синхронизация.

Автосохранение и производительность

На производительность влияют:

  • частота mutation;
  • размер payload;
  • количество invalidateQueries;
  • сложность optimistic update;
  • объём кеша.

Практические рекомендации

Для обычных форм

Подходит:

  • debounce 500–1000 мс;
  • optimistic update;
  • локальный dirty-state.

Для редакторов

Необходимы:

  • очереди mutation;
  • diff-сохранение;
  • batch updates;
  • conflict resolution.

Для мобильных приложений

Особенно важны:

  • retry;
  • offline queue;
  • минимальный размер запросов.

Типичные ошибки

Mutation на каждый символ

Создаёт чрезмерную нагрузку.


invalidateQueries после каждого сохранения

Может вызвать бесконечные циклы refetch.


Отсутствие debounce

Приводит к сотням запросов.


Игнорирование конфликтов

Вызывает потерю пользовательских данных.


Полное сохранение больших документов

Неэффективно при частых изменениях.


Архитектура автосохранения

Крупные приложения обычно разделяют:

  • UI редактирования;
  • локальное состояние;
  • очередь изменений;
  • сетевой слой;
  • синхронизацию кеша;
  • обработку конфликтов.

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

  • масштабировать редакторы;
  • уменьшать сетевую нагрузку;
  • контролировать консистентность данных;
  • обеспечивать устойчивость к ошибкам сети.