Оптимистичные обновления в формах

Оптимистичные обновления — механизм мгновенного изменения интерфейса до получения подтверждения от сервера. Пользователь изменяет форму, отправляет данные, а интерфейс сразу показывает успешный результат, создавая ощущение высокой скорости приложения.

В TanStack Query оптимистичные обновления чаще всего реализуются через useMutation, queryClient.setQueryData и обработчики жизненного цикла мутаций:

  • onMutate
  • onError
  • onSuccess
  • onSettled

Основная идея:

  1. До отправки запроса локальный кеш обновляется вручную.
  2. Интерфейс немедленно отображает новое состояние.
  3. При ошибке состояние откатывается.
  4. После завершения запроса кеш синхронизируется с сервером.

Почему формы особенно нуждаются в оптимистичных обновлениях

Формы — наиболее чувствительная часть интерфейса:

  • редактирование профиля;
  • изменение комментариев;
  • переключатели настроек;
  • лайки;
  • чекбоксы задач;
  • inline-редактирование;
  • drag-and-drop интерфейсы.

Без оптимистичных обновлений пользователь сталкивается с задержками:

  • кнопка зависает;
  • данные обновляются спустя секунду;
  • список «дергается» после refetch;
  • возникает ощущение медленной системы.

Оптимистичный подход устраняет визуальную паузу между действием пользователя и изменением интерфейса.


Базовая схема оптимистичного обновления

Обычная мутация

const mutation = useMutation({
    mutationFn: updateUser
})

Такой код не обновляет интерфейс до ответа сервера.


Оптимистичная мутация

const queryClient = useQueryClient()

const mutation = useMutation({
    mutationFn: updateUser,

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

        const previousUser = queryClient.getQueryData(['user'])

        queryClient.setQueryData(['user'], (old) => ({
            ...old,
            ...newUserData
        }))

        return { previousUser }
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['user'],
            context.previousUser
        )
    },

    onSettled: () => {
        queryClient.invalidateQueries({
            queryKey: ['user']
        })
    }
})

Роль onMutate

onMutate — центральная часть оптимистичных обновлений.

Именно здесь:

  • отменяются активные запросы;
  • сохраняется предыдущее состояние;
  • производится временное изменение кеша.

Почему нужен cancelQueries

await queryClient.cancelQueries({
    queryKey: ['user']
})

Если не отменить активный refetch, возможна гонка состояний:

  1. пользователь изменил форму;
  2. кеш обновился оптимистично;
  3. старый запрос завершился;
  4. сервер прислал старые данные;
  5. интерфейс откатился назад.

Отмена запросов предотвращает подобные конфликты.


Сохранение предыдущего состояния

Получение snapshot

const previousUser = queryClient.getQueryData(['user'])

Snapshot используется для rollback при ошибке.


Возврат контекста

return { previousUser }

Объект автоматически передается в:

  • onError
  • onSettled

Rollback при ошибках

Полный откат

onError: (error, variables, context) => {
    queryClient.setQueryData(
        ['user'],
        context.previousUser
    )
}

Если сервер вернул ошибку:

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

Обновление формы редактирования профиля

Исходный запрос

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

Форма

function ProfileForm() {
    const [name, setName] = useState(user.name)

    const mutation = useMutation({
        mutationFn: updateProfile,

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

            const previousUser =
                queryClient.getQueryData(['user'])

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

            return { previousUser }
        },

        onError: (error, variables, context) => {
            queryClient.setQueryData(
                ['user'],
                context.previousUser
            )
        }
    })

    const handleSubmit = (e) => {
        e.preventDefault()

        mutation.mutate({
            name
        })
    }
}

После отправки:

  • имя мгновенно изменяется;
  • интерфейс выглядит быстрым;
  • серверная задержка скрыта.

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

Добавление элемента

Наиболее распространённый сценарий — создание записи.


Добавление задачи

const mutation = useMutation({
    mutationFn: createTodo,

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

        const previousTodos =
            queryClient.getQueryData(['todos'])

        queryClient.setQueryData(
            ['todos'],
            (old = []) => [
                ...old,
                {
                    id: Date.now(),
                    ...newTodo,
                    pending: true
                }
            ]
        )

        return { previousTodos }
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['todos'],
            context.previousTodos
        )
    },

    onSettled: () => {
        queryClient.invalidateQueries({
            queryKey: ['todos']
        })
    }
})

Временные идентификаторы

При создании сущностей сервер обычно генерирует ID самостоятельно.

До ответа сервера приходится использовать временные идентификаторы:

id: Date.now()

или:

id: crypto.randomUUID()

Маркер временного состояния

Полезно помечать оптимистичные записи:

pending: true

Это позволяет:

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

Обновление элементов внутри массива

Редактирование задачи

queryClient.setQueryData(
    ['todos'],
    (old = []) =>
        old.map((todo) =>
            todo.id === updatedTodo.id
                ? {
                      ...todo,
                      ...updatedTodo
                  }
                : todo
        )
)

Оптимистичное удаление

Удаление особенно выигрывает от мгновенного UI.


Удаление комментария

const mutation = useMutation({
    mutationFn: deleteComment,

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

        const previousComments =
            queryClient.getQueryData(['comments'])

        queryClient.setQueryData(
            ['comments'],
            (old = []) =>
                old.filter(
                    (comment) => comment.id !== commentId
                )
        )

        return { previousComments }
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['comments'],
            context.previousComments
        )
    }
})

Комментарий исчезает мгновенно, без ожидания сервера.


Работа с формами React Hook Form

TanStack Query отлично сочетается с React Hook Form.


Интеграция

const {
    register,
    handleSubmit,
    reset
} = useForm()

Мутация

const mutation = useMutation({
    mutationFn: createPost,

    onSuccess: () => {
        reset()
    }
})

Оптимистичный reset формы

Иногда форма очищается до ответа сервера.


Немедленный reset

const onSub mit = (data) => {
    mutation.mutate(data)

    reset()
}

Такой подход создает ощущение мгновенного завершения операции.


Проблема rollback формы

Если запрос завершился ошибкой:

  • форма уже очищена;
  • введенные данные потеряны.

Сохранение данных перед reset

const onSub mit = (data) => {
    cachedFormData.current = data

    mutation.mutate(data)

    reset()
}

Восстановление при ошибке

onError: () => {
    reset(cachedFormData.current)
}

Синхронизация нескольких запросов

Иногда форма влияет сразу на несколько частей кеша.

Например:

  • список пользователей;
  • профиль пользователя;
  • статистика.

Обновление нескольких query

queryClient.setQueryData(
    ['users'],
    updateUsers
)

queryClient.setQueryData(
    ['user', userId],
    updateUser
)

queryClient.setQueryData(
    ['stats'],
    updateStats
)

Оптимистичные переключатели

Toggle-компоненты

Переключатели — идеальный кандидат для optimistic UI.


Пример

const mutation = useMutation({
    mutationFn: toggleLike,

    onMutate: async () => {
        await queryClient.cancelQueries({
            queryKey: ['post', postId]
        })

        const previousPost =
            queryClient.getQueryData([
                'post',
                postId
            ])

        queryClient.setQueryData(
            ['post', postId],
            (old) => ({
                ...old,
                liked: !old.liked
            })
        )

        return { previousPost }
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['post', postId],
            context.previousPost
        )
    }
})

Избежание мерцания интерфейса

После optimistic update часто возникает flickering:

  1. UI обновился;
  2. refetch вернул старые данные;
  3. затем пришли новые.

Использование точечной синхронизации

Вместо полного invalidate:

onSuccess: (serverData) => {
    queryClient.setQueryData(
        ['user'],
        serverData
    )
}

Так интерфейс остается стабильным.


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

Группировка мутаций

const mutation = useMutation({
    mutationKey: ['update-user'],
    mutationFn: updateUser
})

Это помогает:

  • отслеживать состояние мутаций;
  • строить глобальные индикаторы;
  • анализировать pending-состояния.

Конкурирующие optimistic updates

Сложная проблема — несколько быстрых изменений подряд.

Например:

  1. пользователь быстро изменил имя;
  2. затем email;
  3. затем аватар.

Запросы могут завершиться в другом порядке.


Проблема устаревшего rollback

Если старый запрос завершится ошибкой позже нового успешного запроса:

  • rollback может уничтожить актуальные данные.

Частичное восстановление

Нельзя blindly откатывать весь объект.

Плохой подход:

queryClient.setQueryData(
    ['user'],
    previousUser
)

Безопасное восстановление

queryClient.setQueryData(
    ['user'],
    (current) => ({
        ...current,
        name: context.previousName
    })
)

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

Некоторые backend API возвращают:

{
    "id": 1,
    "name": "Alex",
    "version": 15
}

Versioning помогает:

  • обнаруживать конфликты;
  • предотвращать потерю изменений;
  • корректно объединять optimistic updates.

Optimistic updates и pagination

При пагинации обновление сложнее:

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

Обновление infinite query

queryClient.setQueryData(
    ['posts'],
    (oldData) => ({
        ...oldData,
        pages: oldData.pages.map((page) => ({
            ...page,
            items: page.items.map((item) =>
                item.id === updatedPost.id
                    ? updatedPost
                    : item
            )
        }))
    })
)

Работа с фильтрами

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


Пример ошибки

Пользователь меняет статус задачи:

status: 'completed'

Но текущий фильтр:

status === 'active'

Задача должна исчезнуть из списка сразу.


Корректная логика

queryClient.setQueryData(
    ['todos', 'active'],
    (old = []) =>
        old.filter(
            (todo) => todo.id !== updatedTodo.id
        )
)

Optimistic updates и websocket

При наличии websocket возможны конфликты:

  1. optimistic update изменил кеш;
  2. websocket прислал старое состояние;
  3. UI откатился;
  4. позже сервер отправил новое состояние.

Стратегии решения

Timestamp

updatedAt

Version

version

Source tagging

{
    source: 'optimistic'
}

Отложенная инвалидизация

Иногда invalidateQueries слишком дорогой.


Подход без refetch

onSuccess: (serverTodo) => {
    queryClient.setQueryData(
        ['todos'],
        (old = []) =>
            old.map((todo) =>
                todo.tempId === serverTodo.tempId
                    ? serverTodo
                    : todo
            )
    )
}

Такой подход:

  • снижает сетевую нагрузку;
  • устраняет мерцание;
  • делает UI стабильнее.

Работа с ошибками в UI

Rollback должен сопровождаться визуальной обратной связью.


Toast уведомления

onError: () => {
    toast.error(
        'Не удалось сохранить изменения'
    )
}

Блокировка повторных отправок

Во время optimistic update пользователь может повторно отправить форму.


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

<button disabled={mutation.isPending}>
    Сохранить
</button>

Частичные optimistic updates

Иногда нет смысла обновлять весь объект.


Локальное изменение одного поля

queryClient.setQueryData(
    ['settings'],
    (old) => ({
        ...old,
        theme: 'dark'
    })
)

Optimistic updates и offline-first

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

Оптимистичные обновления особенно важны при нестабильном интернете.


Поведение offline UI

  1. пользователь изменяет форму;
  2. UI обновляется мгновенно;
  3. мутация ставится в очередь;
  4. при восстановлении сети изменения отправляются на сервер.

Persisted cache

Для offline-first часто используется:

persistQueryClient

Это позволяет:

  • сохранять optimistic state;
  • переживать перезагрузку страницы;
  • восстанавливать очередь мутаций.

Распространённые ошибки

Мутация без rollback

onMutate: () => {
    queryClient.setQueryData(...)
}

Без onError кеш может навсегда остаться в неверном состоянии.


Полный invalidate всех query

queryClient.invalidateQueries()

Такой подход:

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

Потеря серверных данных

Опасный код:

{
    ...newData
}

Если сервер хранит дополнительные поля:

  • timestamps;
  • permissions;
  • metadata;

они могут исчезнуть из кеша.


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

Без cancelQueries optimistic update часто становится нестабильным.


Архитектурные рекомендации

Разделение optimistic logic

Полезно выносить optimistic update в отдельные функции:

function optimisticUpdateUser(
    queryClient,
    newUser
) {
    queryClient.setQueryData(
        ['user'],
        (old) => ({
            ...old,
            ...newUser
        })
    )
}

Централизация rollback

function rollbackUser(
    queryClient,
    previousUser
) {
    queryClient.setQueryData(
        ['user'],
        previousUser
    )
}

Использование query key factories

const userKeys = {
    all: ['users'],
    detail: (id) => ['users', id]
}

Это уменьшает количество ошибок в optimistic updates.


Когда optimistic updates не подходят

Не каждое действие безопасно обновлять оптимистично.

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

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

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

  • pessimistic updates;
  • loading state;
  • подтверждение сервера перед обновлением UI.

Практическая стратегия использования

Наиболее удачные сценарии:

  • формы профиля;
  • чекбоксы;
  • лайки;
  • комментарии;
  • локальные настройки;
  • drag-and-drop;
  • задачи;
  • inline editing.

Наиболее опасные:

  • финансовые операции;
  • системы бронирования;
  • конкурентное редактирование;
  • сложные серверные вычисления;
  • транзакционные процессы.