Optimistic UI patterns

Оптимистический UI строится на предположении, что операция на сервере завершится успешно, и интерфейс обновляется заранее, до фактического ответа. TanStack Query предоставляет для этого набор низкоуровневых механизмов вокруг useMutation и QueryClient, позволяющих контролировать кэш, выполнять откаты и синхронизировать состояние после завершения запроса.

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


Базовый механизм optimistic update

Ключевые точки управления находятся в useMutation:

  • onMutate — момент перед отправкой запроса
  • onError — обработка ошибки и откат состояния
  • onSuccess — финальное подтверждение
  • onSettled — синхронизация с сервером

Простейший сценарий обновления списка:

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useToggleTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({ id, completed }) => {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ completed })
      })
      return res.json()
    },

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

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

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

      return { previousTodos }
    },

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

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

Снимок состояния для отката

Ключевая концепция optimistic UI — сохранение snapshot до изменения кэша.

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

Этот снимок используется как источник восстановления при ошибке. TanStack Query не хранит его автоматически — это ответственность прикладного кода.

Типичные данные для восстановления:

  • список сущностей
  • страницы пагинации
  • состояние infinite queries
  • частично изменённые объекты

Инвалидация vs ручное обновление

Существует два подхода после успешной или неуспешной мутации:

1. Ручное обновление кэша

Используется в критичных UI-сценариях, где важна мгновенная консистентность.

queryClient.setQueryData(['todos'], updater)

Преимущество — отсутствие лишнего запроса.


2. Инвалидация

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

Преимущество — гарантированная синхронизация с сервером.

Недостаток — дополнительный сетевой запрос.


Оптимистическое создание сущности

Создание объекта требует временного идентификатора.

function useCreateTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (newTodo) => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo)
      })
      return res.json()
    },

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

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

      const optimisticTodo = {
        id: `temp-${Date.now()}`,
        ...newTodo
      }

      queryClient.setQueryData(['todos'], (old = []) => [
        optimisticTodo,
        ...old
      ])

      return { previous }
    },

    onError: (_err, _vars, ctx) => {
      queryClient.setQueryData(['todos'], ctx.previous)
    },

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

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

Удаление — наиболее простой случай, но требует аккуратного rollback.

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

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

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

  return { previous }
}

Работа с paginated и infinite queries

Infinite queries требуют обновления вложенной структуры:

queryClient.setQueryData(['todos', 'infinite'], (old) => {
  return {
    ...old,
    pages: old.pages.map(page => ({
      ...page,
      data: page.data.filter(todo => todo.id !== id)
    }))
  }
})

Основная сложность — сохранение структуры pages и pageParams.


Согласование конкурентных изменений

При параллельных мутациях возможны гонки состояний. Для их контроля используется:

  • отмена запросов через cancelQueries
  • разделение snapshot по mutation context
  • использование mutationId

Пример изоляции контекста:

onMutate: async (vars) => {
  const previous = queryClient.getQueryData(['todos'])

  return {
    previous,
    mutationId: crypto.randomUUID()
  }
}

Ошибки и восстановление состояния

Rollback происходит только при ошибке мутации:

onError: (_err, _vars, context) => {
  queryClient.setQueryData(['todos'], context.previous)
}

Важно учитывать, что rollback может конфликтовать с новыми данными, пришедшими с сервера. В таких случаях применяется:

  • частичное восстановление
  • merge вместо полной замены
  • повторная инвалидация после rollback

Оптимистическое обновление нескольких ключей

Одна мутация может затрагивать несколько кэшей одновременно:

onMutate: async ({ todo }) => {
  const prevTodos = queryClient.getQueryData(['todos'])
  const prevStats = queryClient.getQueryData(['stats'])

  queryClient.setQueryData(['todos'], updateTodos(todo))
  queryClient.setQueryData(['stats'], updateStats(todo))

  return { prevTodos, prevStats }
}

Согласование с серверной моделью

Сервер всегда остаётся источником истины. Оптимистические изменения:

  • временно изменяют UI
  • не гарантируют финальное состояние
  • требуют синхронизации через invalidateQueries или merge-обновление

Типичный гибридный подход:

  1. optimistic update через setQueryData
  2. подтверждение через onSuccess
  3. финальная синхронизация через invalidateQueries

Типичные ошибки реализации optimistic UI

Потеря snapshot

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

Полная замена сложных структур

При infinite queries или вложенных объектах это приводит к разрушению кэша.

Игнорирование отмены запросов

Без cancelQueries возможны race conditions между optimistic и серверными данными.

Несогласованное обновление разных queryKey

Один и тот же объект должен обновляться во всех связанных кэшах.


Паттерн «локальный источник истины + серверная валидация»

В сложных интерфейсах применяется модель:

  • UI мгновенно отражает локальное состояние
  • сервер подтверждает изменения
  • TanStack Query синхронизирует кэш
onSettled: () => {
  queryClient.invalidateQueries({ queryKey: ['todos'] })
  queryClient.invalidateQueries({ queryKey: ['stats'] })
}

Оптимистические обновления и задержка сети

Чем выше latency, тем сильнее эффект оптимистического UI. При низкой задержке серверные данные могут приходить быстрее, чем rollback или sync, что создаёт визуальные скачки. Для сглаживания применяется:

  • debounce invalidation
  • минимизация лишних refetch
  • локальный merge вместо полного перезапроса

Частичная корректировка данных после ответа сервера

Иногда сервер возвращает не полную сущность, а дельту:

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

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


Комбинирование optimistic UI с cacheTime и staleTime

Оптимистические обновления тесно связаны с политикой кэша:

  • staleTime влияет на частоту рефетча
  • cacheTime определяет срок жизни optimistic данных

При агрессивных optimistic updates слишком маленький cacheTime приводит к частому сбросу состояния, нивелируя эффект мгновенного UI.