Optimistic updates

Optimistic update — подход, при котором интерфейс изменяется ещё до завершения HTTP-запроса. Пользователь нажимает кнопку, а данные мгновенно обновляются локально, будто сервер уже подтвердил операцию.

Классический сценарий:

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

Без optimistic updates:

  1. Пользователь нажимает кнопку.
  2. Отправляется запрос.
  3. Интерфейс ждёт ответ.
  4. После ответа UI обновляется.

С optimistic updates:

  1. Пользователь нажимает кнопку.
  2. Интерфейс мгновенно меняется.
  3. Запрос отправляется в фоне.
  4. При ошибке выполняется rollback.

Такой подход делает интерфейс визуально быстрым даже при медленном интернете.


Где optimistic updates особенно полезны

Социальные сети

  • лайки;
  • подписки;
  • комментарии;
  • реакции.

CRM и админки

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

Todo-приложения

  • добавление задач;
  • завершение задач;
  • reorder списка.

Чаты

  • отправка сообщений;
  • редактирование сообщений;
  • удаление сообщений.

Основной механизм в TanStack Query

Optimistic updates обычно строятся через:

  • useMutation
  • onMutate
  • onError
  • onSettled
  • queryClient.setQueryData

Ключевая идея:

  1. До запроса сохранить старое состояние.
  2. Немедленно изменить cache.
  3. При ошибке восстановить старые данные.
  4. После завершения синхронизироваться с сервером.

Базовый пример optimistic update

Исходные данные

[
  { id: 1, title: 'Learn JS', completed: false },
  { id: 2, title: 'Learn React', completed: false }
]

Запрос списка

import { useQuery } from '@tanstack/react-query'

function Todos() {
  const { data } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })

  return (
    <div>
      {data?.map(todo => (
        <div key={todo.id}>
          {todo.title}
        </div>
      ))}
    </div>
  )
}

Мутация с optimistic update

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

function TodoItem({ todo }) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: updateTodo,

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

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

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

      return { previousTodos }
    },

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

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

  return (
    <button
      onCl ick={() => {
        mutation.mutate({
          id: todo.id,
          completed: !todo.completed
        })
      }}
    >
      Toggle
    </button>
  )
}

Разбор onMutate

onMutate запускается до выполнения mutationFn.

Именно здесь выполняется optimistic update.

async onMutate(updatedTodo) {

}

Аргумент:

updatedTodo

— данные, переданные в mutate.


Почему используется cancelQueries

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

Во время optimistic update может выполняться refetch.

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

  1. UI обновил cache.
  2. Старый запрос завершился позже.
  3. Старые данные затёрли optimistic state.

cancelQueries предотвращает такую гонку.


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

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

Rollback невозможен без snapshot старых данных.

Чаще всего сохраняют:

  • массив;
  • объект;
  • часть store;
  • конкретную запись.

Изменение cache через setQueryData

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

setQueryData обновляет cache синхронно.

UI перерисовывается мгновенно.

Запрос к серверу ещё даже может не начаться.


Что возвращает onMutate

return { previousTodos }

Возвращаемое значение попадает в context.

Позже оно доступно:

onError(error, variables, context)

Это основной механизм rollback.


Rollback через onError

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

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

  • optimistic state удаляется;
  • cache возвращается к старому состоянию;
  • UI автоматически восстанавливается.

Зачем нужен onSettled

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

Даже после успешного optimistic update cache может отличаться от сервера.

Например:

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

invalidateQueries выполняет финальную синхронизацию.


Optimistic update при добавлении элемента

Пример добавления todo

const mutation = useMutation({
  mutationFn: createTodo,

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

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

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

    return { previousTodos }
  },

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

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

Временные ID

При optimistic create серверного ID ещё нет.

Поэтому используют временные значения:

id: Date.now()

или:

id: crypto.randomUUID()

Поле optimistic

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

optimistic: true

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

  • показывать loader;
  • делать opacity;
  • блокировать действия;
  • визуально обозначать pending state.

Пример отображения optimistic элемента

{
  data?.map(todo => (
    <div
      key={todo.id}
      style={{
        opacity: todo.optimistic ? 0.5 : 1
      }}
    >
      {todo.title}
    </div>
  ))
}

Optimistic delete

Удаление элемента до ответа сервера

const mutation = useMutation({
  mutationFn: deleteTodo,

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

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

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

    return { previousTodos }
  },

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

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

Преимущества optimistic delete

Без optimistic delete интерфейс выглядит медленным:

  1. Пользователь нажимает удалить.
  2. Элемент остаётся.
  3. Идёт ожидание.
  4. После ответа элемент исчезает.

Optimistic delete убирает задержку полностью.


Optimistic toggle

Переключение boolean-поля

Очень частый кейс:

liked: true
completed: false
enabled: true
archived: false

Пример

queryClient.setQueryData(
  ['posts'],
  old => {
    return old.map(post => {
      if (post.id !== postId) {
        return post
      }

      return {
        ...post,
        liked: !post.liked
      }
    })
  }
)

Такие обновления особенно хорошо подходят для optimistic UI, потому что:

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

Optimistic update для detail query

Не всегда обновляется список.

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

['todo', id]

Пример

queryClient.setQueryData(
  ['todo', todo.id],
  old => ({
    ...old,
    title: updatedTitle
  })
)

Синхронизация списка и detail query

Частая проблема:

['todos']
['todo', 5]

Обе записи cache содержат один объект.

При optimistic update необходимо синхронизировать оба cache entry.


Пример

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

queryClient.setQueryData(
  ['todo', updated.id],
  updated
)

Concurrent optimistic updates

Проблема параллельных мутаций

Сценарий:

  1. Пользователь быстро нажал toggle несколько раз.
  2. Отправилось несколько запросов.
  3. Ответы пришли в другом порядке.
  4. Cache стал неконсистентным.

Потенциальные проблемы

Lost update

Более старый ответ перезаписал новый state.

Некорректный rollback

Одна мутация откатила изменения другой.

Race conditions

Порядок ответов отличается от порядка действий пользователя.


Подходы к решению concurrency-проблем

Блокировка интерфейса

Самый простой вариант:

disabled={mutation.isPending}

Недостаток:

  • UX хуже;
  • интерфейс становится менее отзывчивым.

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

useMutation({
  mutationKey: ['toggleTodo']
})

Позволяет отслеживать конкретные mutation.


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

const pendingMutations =
  useMutationState({
    filters: {
      mutationKey: ['toggleTodo'],
      status: 'pending'
    }
  })

Можно анализировать активные optimistic updates.


Optimistic updates и infinite queries

Infinite query хранит страницы:

{
  pages: [],
  pageParams: []
}

Обновление становится сложнее.


Пример обновления infinite query

queryClient.setQueryData(
  ['feed'],
  old => {
    return {
      ...old,
      pages: old.pages.map(page => {
        return page.map(post => {
          if (post.id === updated.id) {
            return updated
          }

          return post
        })
      })
    }
  }
)

Optimistic reorder

Drag-and-drop сортировка

Один из самых сложных optimistic scenarios.


Пример reorder

queryClient.setQueryData(
  ['tasks'],
  old => {
    const copy = [...old]

    const [removed] = copy.splice(
      sourceIndex,
      1
    )

    copy.splice(
      destinationIndex,
      0,
      removed
    )

    return copy
  }
)

Когда optimistic updates опасны

Финансовые операции

Нельзя optimistic обновлять:

  • баланс;
  • банковские транзакции;
  • оплату;
  • списание средств.

Критически важные данные

Опасные сценарии:

  • медицинские системы;
  • юридические данные;
  • складской учёт;
  • бронирование мест.

Операции с высокой вероятностью ошибки

Если backend часто отвечает ошибками:

  • optimistic UI будет постоянно откатываться;
  • интерфейс станет дёрганым;
  • UX ухудшится.

Разница между optimistic update и invalidateQueries

invalidateQueries

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

Подход:

  1. ждать сервер;
  2. потом refetch.

Optimistic update

onMutate() {
  queryClient.setQueryData(...)
}

Подход:

  1. сначала UI;
  2. потом сервер.

Комбинирование optimistic update и invalidateQueries

На практике чаще используют оба подхода одновременно:

onMutate()
onError()
onSettled()

Где:

  • onMutate → optimistic state;
  • onError → rollback;
  • onSettled → refetch.

Обновление cache без refetch

Иногда invalidateQueries не нужен.


Пример

Сервер возвращает актуальный объект:

mutationFn: updateTodo

Ответ:

{
  id: 5,
  title: 'Updated',
  completed: true
}

Тогда можно обновить cache напрямую:

onSuccess(updatedTodo) {
  queryClient.setQueryData(
    ['todo', updatedTodo.id],
    updatedTodo
  )
}

Частые ошибки

Отсутствие rollback

Ошибка:

onMutate() {
  queryClient.setQueryData(...)
}

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


Отсутствие cancelQueries

Старый refetch способен затереть optimistic cache.


Мутация исходных данных

Ошибка:

old.push(newTodo)
return old

Нельзя мутировать cache напрямую.

Правильно:

return [...old, newTodo]

Неполная синхронизация cache

Обновлён:

['todos']

Но не обновлён:

['todo', id]

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


Практический шаблон optimistic update

const mutation = useMutation({
  mutationFn: apiRequest,

  async onMutate(variables) {
    await queryClient.cancelQueries({
      queryKey: ['resource']
    })

    const previousData =
      queryClient.getQueryData(['resource'])

    queryClient.setQueryData(
      ['resource'],
      old => {
        return optimisticUpdate(old)
      }
    )

    return { previousData }
  },

  onError(error, variables, context) {
    queryClient.setQueryData(
      ['resource'],
      context.previousData
    )
  },

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

Архитектурные особенности optimistic UI

Cache становится источником истины

Во время optimistic update именно cache управляет UI.

Не сервер.

Это важный архитектурный момент.


Сервер превращается в eventual consistency

Интерфейс временно может не совпадать с backend.

Такое рассогласование считается допустимым.


Optimistic UI требует предсказуемых операций

Лучше всего работают операции:

  • toggle;
  • create;
  • delete;
  • patch;
  • reorder.

Хуже подходят:

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

Визуальные состояния optimistic UI

Полезно различать:

  • pending;
  • success;
  • rollback;
  • retry.

Пример pending-состояния

{
  optimistic: true,
  saving: true
}

Пример UI

<div
  style={{
    opacity: todo.saving ? 0.5 : 1
  }}
>
  {todo.title}
</div>

Retry и optimistic updates

TanStack Query умеет автоматически повторять mutation.

retry: 3

Но вместе с optimistic update это требует осторожности.


Возможная проблема

  1. UI обновился optimistic.
  2. Запрос упал.
  3. Начался retry.
  4. UI уже находится в optimistic state.

Rollback может стать сложным.


Практический подход

Часто для optimistic mutation retry отключают:

retry: false

Или делают собственную стратегию retry.


Devtools и optimistic updates

Devtools TanStack Query особенно полезны для:

  • просмотра cache;
  • анализа rollback;
  • отслеживания invalidateQueries;
  • анализа race conditions;
  • просмотра pending mutations.

Во время optimistic update хорошо видно:

  • как изменяется cache;
  • когда происходит rollback;
  • когда выполняется refetch;
  • какие query становятся stale.