Данные сервера, клиентское состояние, владелец кэша, оптимистичные изменения, состояние URL и выбор хранилища.
Большинство проблем управления состоянием начинаются, когда данные разных владельцев и с разным сроком жизни складывают в одно хранилище.
Вы классифицируете данные, выберете главный источник, правила кеширования и обновления и согласование параллельных изменений.
Понадобятся редьюсеры, маршрутизация, действия, асинхронный интерфейс и внешние хранилища. Итог урока — карта владельцев данных и рабочий адаптер кеша с предварительным обновлением и восстановлением после конфликта.
Различайте локальное состояние интерфейса, черновик формы, URL, серверные данные и кеш, сеанс и внешние данные реального времени. Для каждого укажите главный источник (source of truth), область действия, сохранение, источник обновления и требования согласованности. Одно «глобальное состояние» скрывает различия и создаёт неверные правила сброса и обновления.
Карту владельцев полезно писать до выбора библиотеки — тогда видно, что данные из одной таблицы требуют разных механизмов:
| Данные | Владелец | Срок жизни | Механизм |
|---|---|---|---|
| Наведение, раскрытая строка | компонент | до размонтирования | useState |
| Черновик формы | компонент формы | до отправки | useState / useReducer |
| Фильтр, сортировка, страница | URL | до смены ссылки | параметры поиска |
| Список заявок, справочники | сервер | до истечения свежести | кеш запросов |
| Профиль и права сеанса | сервер | до выхода | провайдер + серверная проверка |
Строки этой таблицы нельзя объединить: у наведения нет удалённого владельца, у списка заявок нет смысла в сохранении между сеансами, а токен доступа не должен попадать в сохраняемое хранилище вообще.
Проверьте себя. Заполните таблицу данных продукта до выбора библиотеки.
Частая ошибка. Положить наведение, токен доступа и серверный список в одно сохраняемое хранилище.
У серверных данных есть удалённый владелец; они могут устареть, загружаться с ошибкой и меняться без клиента. Кеш хранит снимок с ключом, сроком свежести и правилом обновления. Копирование результата запроса в клиентское хранилище создаёт второй источник истины, если нет независимого черновика или сценария.
Второй источник истины обычно появляется как «безобидный» эффект синхронизации:
// ❌ src/features/tickets/useTickets.ts — копия серверных данных в хранилище
const { data } = useQuery({ queryKey: ['tickets'], queryFn: fetchTickets });
useEffect(() => {
if (data) dispatch(setTickets(data)); // теперь актуальных списков два
}, [data, dispatch]);После такого эффекта любое обновление кеша нужно вручную дублировать в хранилище, а любая ошибка синхронизации даёт интерфейс, показывающий устаревшие данные. Правильный вариант — читать данные там, где они уже есть:
// ✅ один владелец: кеш запросов
const { data: tickets = [], isFetching } = useQuery({
queryKey: ['tickets'],
queryFn: fetchTickets,
});Клиентское хранилище остаётся нужным только для того, чего нет на сервере: черновиков, выбранных элементов, режимов интерфейса.
Проверьте себя. Удалите эффект, копирующий результат запроса в Redux или локальное состояние.
Частая ошибка. После каждого fetch отправлять полный список ещё в одно хранилище.
Ключ включает все входы, влияющие на результат: ресурс, идентификатор, фильтры, организацию и пользователя. Один логический запрос получает один ключ; разные области авторизации не делят приватные данные. Объекты параметров приводят к канонической форме по правилам библиотеки.
Ключи стоит собирать в одном месте — тогда их можно протестировать и нельзя случайно «забыть» вход:
// src/features/tickets/keys.ts
export const ticketKeys = {
all: (orgId: string) => ['tickets', orgId] as const,
list: (orgId: string, filters: TicketFilters) =>
[...ticketKeys.all(orgId), 'list', filters.status, filters.page] as const,
detail: (orgId: string, id: number) => [...ticketKeys.all(orgId), 'detail', id] as const,
};Ключ ['tickets'] без orgId — это не только неверный кеш, но и утечка между арендаторами: пользователь второй организации получит из кеша данные первой. Явное перечисление полей фильтра вместо целого объекта защищает от того, что новое поле фильтра появится, а ключ останется прежним.
Проверьте себя. Создайте функцию ключей и тесты для фильтров и организаций.
Частая ошибка. Один ключ products для всех организаций и фильтров.
Срок свежести (staleTime) — продуктовое правило допустимой давности, а не магическое ускорение. При обновлении связанные записи помечаются устаревшими или заменяются данными ответа. Общее обновление проще, но дороже; точечное требует знать зависимости. Время сборки мусора регулирует память, а не свежесть.
Разные данные заслуживают разных правил, и это решение предметное, а не техническое:
// src/features/catalog/queries.ts
const currencies = useQuery({
queryKey: ['currencies'],
queryFn: fetchCurrencies,
staleTime: 24 * 60 * 60 * 1000, // справочник меняется раз в сутки
});
const stock = useQuery({
queryKey: ['stock', sku],
queryFn: () => fetchStock(sku),
staleTime: 15 * 1000, // остаток склада устаревает почти сразу
});Если поставить справочнику staleTime: 0, приложение будет тратить сеть на данные, которые не меняются. Если поставить остатку сутки — пользователь продаст товар, которого нет. gcTime в обоих случаях отвечает только за то, когда неиспользуемый снимок выгрузят из памяти.
Проверьте себя. Определите срок свежести отдельно для справочника и остатков склада.
Частая ошибка. Считать динамические данные вечно свежими без внешних уведомлений или обновления кеша.
Перед изменением снимите затрагиваемый кеш и примените предварительную правку (optimistic update). При отказе откатите её или перечитайте данные, при успехе согласуйте ответ сервера. Параллельным изменениям нужны идентификатор и версия. Слепой откат старого снимка может стереть более новое успешное изменение.
Транзакция состоит из четырёх шагов, и каждый из них обязателен:
// src/features/cart/useUpdateQuantity.ts
useMutation({
mutationFn: updateQuantity,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['cart'] }); // 1. остановить конкурентов
const snapshot = queryClient.getQueryData<Cart>(['cart']); // 2. снять снимок
queryClient.setQueryData<Cart>(['cart'], (cart) => applyQuantity(cart, variables));
return { snapshot };
},
onError: (_error, _variables, context) => {
// 3. откат именно того снимка, который сняла эта мутация
if (context?.snapshot) queryClient.setQueryData(['cart'], context.snapshot);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] }); // 4. согласовать с сервером
},
});Снимок обязан жить в контексте конкретной мутации. Общая переменная модуля на все параллельные изменения — это гарантированная потеря данных: откат второго изменения вернёт состояние до первого и сотрёт уже подтверждённый сервером результат.
Проверьте себя. Завершите два изменения количества в обратном порядке.
Частая ошибка. Одно общее предыдущее состояние для всех параллельных изменений.
При конфликте редактирования сервер возвращает несовпадение версии или ETag. Интерфейс не должен молча перезаписывать чужое изменение или терять черновик. Покажите актуальное серверное значение, локальный черновик и варианты объединения или повтора. Правило «последняя запись побеждает» (last write wins) допустимо только как осознанное решение предметной области.
Версия передаётся на сервер и проверяется им; клиент лишь корректно обрабатывает отказ:
// src/features/tickets/save.ts
const response = await fetch(`/api/tickets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': etag },
body: JSON.stringify(draft),
});
if (response.status === 409) {
const server = (await response.json()) as Ticket;
// черновик не выбрасываем: пользователь решает, что оставить
return { kind: 'conflict', server, draft } as const;
}Автоматический повтор того же PUT после 409 — это и есть молчаливая перезапись чужой работы: сервер отказал именно потому, что данные изменились. Экран сравнения стоит дороже в разработке, но сохраняет и намерение пользователя, и чужое изменение.
Проверьте себя. Смоделируйте 409 и экран сравнения.
Частая ошибка. Автоматически повторить PUT без версии после ответа 409.
Сохранённое клиентское состояние становится долговременным форматом. Храните минимум, добавляйте версию и правила миграции или очистки, не сохраняйте секреты и серверный кеш без необходимости. localStorage синхронен, доступен скриптам и может содержать повреждённую строку — чтение начинается с unknown и try/catch.
Чтение сохранённых настроек — это разбор недоверенного ввода, а не восстановление объекта:
// src/shared/settings/storage.ts
const CURRENT_VERSION = 2;
export function readSettings(): Settings {
try {
const raw: unknown = JSON.parse(localStorage.getItem('settings') ?? 'null');
if (!isRecord(raw)) return defaultSettings;
if (raw.version === 1) return migrateV1toV2(raw); // старый формат ещё в браузерах
if (raw.version === CURRENT_VERSION) return parseSettings(raw);
return defaultSettings; // формат из будущей версии читать не пытаемся
} catch {
return defaultSettings; // повреждённая строка не должна ломать приложение
}
}Обратите внимание на три отдельных исхода: старая версия — миграция, текущая — разбор, всё остальное — значения по умолчанию. Без версии в записи вы не отличите одно от другого, и первое же изменение формы настроек уронит приложение у существующих пользователей.
Проверьте себя. Перенесите настройки с версии 1 на версию 2 и обработайте неверный JSON.
Частая ошибка. Сохранять всё глобальное хранилище, включая токен доступа и временные ошибки.
Сравнивайте решения по селекторам, средствам диагностики, асинхронной работе, кешу, SSR, размеру пакета, экосистеме и опыту команды. Redux Toolkit подходит сложным клиентским переходам, TanStack Query — серверному кешу, Context — передаче стабильной зависимости, загрузчики маршрутов — данным навигации. Комбинация нормальна при чётких границах.
Обоснование выбора звучит как связка «требование → механизм», и в нём обязательно есть отказы:
Фильтры и страница → URL: нужен обмен ссылкой и кнопка «назад».
Список и деталь заявки → TanStack Query: удалённый владелец, свежесть 30 с, предварительные правки.
Черновик формы → useReducer в форме: живёт до отправки, инварианты проверяются переходами.
Сеанс → Context над результатом серверной проверки: стабильная зависимость без частых обновлений.
Redux Toolkit → не берём: нет сложных клиентских переходов, которые не покрываются перечисленным.Последняя строка — самая полезная. Выбор одного хранилища «чтобы всё было в одном месте» обычно означает, что серверный кеш придётся написать вручную, а вместе с ним свежесть, дедупликацию запросов и откат правок.
Проверьте себя. Кратко обоснуйте набор средств состояния, включая отказ от ненужных слоёв.
Частая ошибка. Выбрать одно хранилище для всего только ради единого инструмента диагностики.
Спроектируйте интерфейс склада: фильтры в URL, черновик в состоянии формы, сеанс в провайдере, товары в серверном кеше. Добавьте срок свежести, функцию ключей, предварительное изменение количества, конфликт версии 409 и правило отката или повторной загрузки.
Работа готова, когда серверные сущности без причины не копируются в глобальное клиентское хранилище, ключи кеша детерминированы, а конфликт не теряет ввод пользователя.
Далее: Тестирование React-приложений