Управление состоянием: локальное, контекст, Redux, Zustand, сигналы
В предыдущей статье мы разобрали хуки: useState даёт компоненту память, useEffect синхронизирует его с внешним миром. Пока состояние живёт внутри одного компонента, вопросов нет. Они начинаются, когда одно значение нужно трём экранам, двум модалкам и хедеру одновременно — и когда после каждого нажатия клавиши в поиске перерисовывается таблица на 500 строк.
«Управление состоянием» — самая мифологизированная тема фронтенда: вокруг неё выросла индустрия библиотек и карго-культа. На практике 80 % проблем решается тремя вопросами: какого рода это состояние, кто им должен владеть и насколько узкая нужна подписка. Библиотека — следствие ответов, а не начало разговора.
Дальше — модель. Мы разберём таксономию состояния, честно посмотрим, чем Context не является, напишем свой стор на 30 строк и свои сигналы на 40 (лучший способ перестать считать их магией), пройдём по Redux Toolkit, Zustand, Jotai и XState и научимся измерять, почему тормозит именно ваше приложение. Язык здесь не обсуждаем: типы, дженерики и tsconfig — в треке TypeScript.
Состояние — это не одна вещь
Главная ошибка — считать «состояние приложения» однородным и класть его в одно место. Это как считать, что все данные компании должны лежать в одной таблице. У разных видов состояния разное время жизни, разный владелец и разные требования к консистентности.
Практическое следствие: до 70 % того, что раньше клали в Redux, — серверный кэш, и ему место не в редьюсерах, а в специализированном инструменте вроде React Query (следующая статья). Ещё изрядная доля — состояние навигации, которому место в URL: тогда ссылка на отфильтрованный список работает, кнопка «назад» работает, перезагрузка ничего не теряет.
| Вид состояния | Где хранить | Что ломается при неправильном месте |
|---|---|---|
| Ответы API | React Query / RTK Query / SWR | ручная инвалидация, гонки, дубли запросов |
| Фильтры, вкладка, страница | URL — searchParams |
ссылка «поделиться» и кнопка «назад» не работают |
| Открытая модалка, ховер | useState рядом |
лишние перерисовки половины приложения |
| Тема, локаль, юзер | Context или маленький стор | prop drilling через восемь уровней |
| Корзина, черновик, редактор | внешний стор | потеря данных при размонтировании |
| Токен доступа | cookie HttpOnly |
XSS-кража токена из localStorage |
Дальше идём снизу вверх по сложности. Правило переходов: поднимайся на следующий уровень только тогда, когда текущий явно не справился, и умей назвать, чем именно.
Уровень 0: локальное состояние
Не храните то, что можно вычислить
Самый частый источник багов — производное состояние, продублированное в useState. Два источника истины неизбежно расходятся.
// ❌ full живёт отдельно и рассинхронизируется при любой правке
const [first, setFirst] = useState('');
const [last, setLast] = useState('');
const [full, setFull] = useState(''); // третий источник истины
// ✅ производное значение просто вычисляется при рендере
const full = `${first} ${last}`.trim(); // всегда согласовано, стоит наносекунды
То же со списками: не кладите filteredItems в состояние — фильтруйте при рендере, а при дороговизне оберните в useMemo. Состоянием должно быть только то, что нельзя вывести из другого состояния и props.
Две технические мелочи, на которых спотыкаются: setCount(count + 1) читает значение из замыкания (три клика подряд дадут +1) — правильна функциональная форма setCount(c => c + 1); а инициализатор вида useState(() => JSON.parse(...)) выполняется один раз, тогда как useState(JSON.parse(...)) — на каждом рендере. С React 18 батчинг работает везде — в обработчиках, промисах, таймерах, — поэтому несколько setState подряд дают один рендер.
Подъём состояния и его цена
Когда двум соседям нужно общее значение, оно поднимается к ближайшему общему предку. Цена подъёма: всё поддерево ниже владельца участвует в каждом обновлении. Подняли состояние поля поиска в App — каждое нажатие клавиши рендерит App и всё, что он вернул, кроме отсечённого React.memo или переданного как children.
// Приём «children как дырка»: SearchBox меняется на каждое нажатие,
// но <HeavyTable/> создан выше, ссылка на элемент стабильна → React делает bailout
function SearchBox({ children }: { children: React.ReactNode }) {
const [q, setQ] = useState('');
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<Results query={q} />
{children}
</>
);
}
<SearchBox><HeavyTable /></SearchBox>
useReducer: когда переходов больше, чем полей
useState описывает данные, useReducer — переходы. Три-четыре флага, которые не могут быть истинными одновременно (isLoading, isError, isSuccess), — признак того, что вы кодируете конечный автомат россыпью булевых переменных. Редьюсер делает невозможные состояния невыразимыми.
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; items: Item[] }
| { status: 'error'; message: string };
type Action =
| { type: 'fetch' }
| { type: 'resolve'; items: Item[] }
| { type: 'reject'; message: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
// Второй fetch во время загрузки просто игнорируется — перехода нет
case 'fetch': return state.status === 'loading' ? state : { status: 'loading' };
case 'resolve': return { status: 'success', items: action.items };
case 'reject': return { status: 'error', message: action.message };
default: {
const never: never = action; // исчерпывающая проверка на этапе компиляции
return never;
}
}
}
const [state, dispatch] = useReducer(reducer, { status: 'idle' });
Бонус: редьюсер — чистая функция, её тестируют без рендера и DOM обычным expect(reducer(prev, action)).toEqual(next). Про тестовую пирамиду фронтенда — в статье о тестировании.
Уровень 1: Context — и чем он не является
Как он работает на самом деле
createContext не хранит данные. Он создаёт канал: провайдер кладёт в него значение, а useContext находит ближайший провайдер вверх по дереву и подписывается на изменение его value. Хранит данные всё равно чей-то useState.
Ключевой факт: у подписки нет гранулярности. Изменился value — обновятся все потребители, независимо от того, какое поле они читают. React.memo на промежуточных компонентах не спасает: контекст «пробивает» мемоизацию, потому что обновление доставляется потребителю напрямую, минуя props.
Марк Эриксон, мейнтейнер Redux, написал об этом лучшую статью в жанре развенчания — Why React Context is Not a “State Management” Tool. Тезис короткий: Context — механизм внедрения зависимостей (передать значение вниз, минуя props), а не управления состоянием и уж точно не средство оптимизации.
Три ошибки, которые делают все
// ❌ 1. Новый объект на каждом рендере провайдера → потребители обновляются всегда
<UserContext.Provider value={{ user, setUser }}>
// ✅ мемоизируем значение
const value = useMemo(() => ({ user, setUser }), [user]);
// ❌ 2. Данные и действия в одном контексте: компоненту нужен только logout(),
// а он перерисовывается при каждой смене профиля.
// ✅ два контекста: стабильные действия отдельно от меняющихся данных
const UserStateContext = createContext<User | null>(null);
const UserApiContext = createContext<UserApi | null>(null); // value стабилен всегда
// ✅ 3. И всегда — проверка провайдера вместо «cannot read property of null»
export function useUser() {
const ctx = useContext(UserStateContext);
if (ctx === null) throw new Error('useUser должен вызываться внутри <UserProvider>');
return ctx; // тип сузился до User, ошибка человеческая
}
Разделение «данные / действия» — самый дешёвый и недооценённый приём: dispatch и сеттеры стабильны по ссылке, поэтому контекст действий не обновляется никогда, и половина потребителей выпадает из перерисовок.
Context хорош, когда значение меняется редко относительно частоты рендеров: тема, локаль, пользователь, фичефлаги, инстанс стора (да, чаще всего его используют именно так — прокинуть стор, а не состояние). Он плох, когда значение меняется часто или когда потребителей много, а каждому нужен свой маленький кусочек.
Уровень 2: внешний стор и модель подписки
Когда нужна гранулярность, состояние переезжает из дерева наружу. Стор — обычный объект в модуле, компоненты на него подписываются. React с версии 18 даёт официальный примитив — useSyncExternalStore. Все современные библиотеки (Zustand, Redux, Valtio) внутри используют именно его: он корректно работает с конкурентным рендерингом и решает проблему «разорванного» состояния (tearing), когда разные компоненты в одном кадре видят разные версии данных.
// store/create-store.ts — рабочий код, можно брать в проект
type Listener = () => void;
export type Store<T> = {
getState: () => T;
setState: (patch: Partial<T> | ((prev: T) => Partial<T>)) => void;
subscribe: (listener: Listener) => () => void;
};
export function createStore<T extends object>(initial: T): Store<T> {
let state = initial;
const listeners = new Set<Listener>();
return {
getState: () => state,
setState(patch) {
const partial = typeof patch === 'function' ? patch(state) : patch;
// Если ни одно поле реально не изменилось — не создаём новый снимок
// и не будим подписчиков: иначе каждый setState = рендер всех читателей.
const changed = Object.keys(partial).some(
(k) => !Object.is((partial as never)[k], (state as never)[k]),
);
if (!changed) return;
state = { ...state, ...partial }; // новая ссылка = сигнал «снимок другой»
for (const l of [...listeners]) l(); // копия: слушатель может отписаться внутри
},
subscribe(listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
};
}
Теперь мост в React. Здесь главная ловушка useSyncExternalStore: getSnapshot обязан возвращать закэшированное значение. Если селектор строит новый объект на каждом вызове, React увидит «состояние изменилось» бесконечное число раз и упадёт с The result of getSnapshot should be cached to avoid an infinite loop.
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
export function useStore<T extends object, S>(
store: Store<T>,
selector: (state: T) => S,
isEqual: (a: S, b: S) => boolean = Object.is,
): S {
return useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
store.getState, // getServerSnapshot — для SSR, см. раздел про гидратацию
selector,
isEqual, // сравнивается результат селектора, а не всё состояние
);
}
Пакет use-sync-external-store поставляется командой React; его версия с селектором кэширует результат и сравнивает переданным компаратором. Именно поэтому в Zustand есть useShallow — поверхностное сравнение для селекторов, возвращающих объект или массив.
Это модель работы любого современного стора. Дальше отличаются синтаксис, набор middleware и объём церемоний.
Redux Toolkit: скучный, предсказуемый, живой
Redux хоронят каждый год, а он остаётся стандартом в крупных продуктах — и не по инерции. Его сильные стороны видны там, где команда больше десяти человек и жизнь приложения дольше трёх лет: единый словарь событий, сериализуемая история действий, time-travel debugging, тестируемые чистые редьюсеры и невозможность «по-быстрому мутировать состояние из соседнего модуля».
Классический Redux был многословен до боли. Redux Toolkit (RTK) убрал 80 % кода; писать «на голом Redux» в 2026 году — ошибка, о чём прямо говорит Redux Style Guide.
// features/cart/cart-slice.ts
import { createSlice, createSelector, type PayloadAction } from '@reduxjs/toolkit';
type CartItem = { id: string; price: number; qty: number };
type CartState = { items: Record<string, CartItem> };
const cartSlice = createSlice({
name: 'cart',
initialState: { items: {} } as CartState,
reducers: {
// Внутри createSlice работает Immer: «мутирующий» код превращается
// в иммутабельное обновление структурным шарингом. Мутировать можно
// ТОЛЬКО state из аргумента, не внешние объекты.
added(state, action: PayloadAction<Omit<CartItem, 'qty'>>) {
const item = state.items[action.payload.id];
if (item) item.qty += 1;
else state.items[action.payload.id] = { ...action.payload, qty: 1 };
},
removed(state, action: PayloadAction<string>) {
delete state.items[action.payload];
},
},
});
export const { added, removed } = cartSlice.actions;
export default cartSlice.reducer;
// Селекторы — единственное место, где компоненты знают форму состояния.
// createSelector мемоизирует по ссылкам аргументов: массив не пересоздаётся,
// пока не изменился items, поэтому подписчики не будятся зря.
const selectItemsMap = (s: RootState) => s.cart.items;
export const selectItems = createSelector([selectItemsMap], (m) => Object.values(m));
export const selectTotal = createSelector([selectItems], (items) =>
items.reduce((sum, it) => sum + it.price * it.qty, 0),
);
// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
import cart from '../features/cart/cart-slice';
// devtools, thunk и проверки на мутации/несериализуемость включены по умолчанию
export const store = configureStore({ reducer: { cart } });
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// react-redux 9: типизированные хуки одной строкой
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
useSelector сравнивает результат через Object.is, поэтому селектор обязан быть стабильным по ссылке — иначе рендер на каждое действие в приложении, включая чужие.
Нормализация. Хранить сущности массивом объектов — путь к O(n) поиску при каждом обновлении и к каскадным перерисовкам. createEntityAdapter держит данные в форме { ids, entities } — ровно как реляционная таблица с первичным ключом, обновление одной записи стоит O(1).
const usersAdapter = createEntityAdapter<User>({ sortComparer: (a, b) => a.name.localeCompare(b.name) });
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({ loading: false }),
reducers: { upsertMany: usersAdapter.upsertMany, removeOne: usersAdapter.removeOne },
});
export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors((s: RootState) => s.users);
К RTK прилагается RTK Query — слой серверного кэша с дедупликацией запросов, тегами инвалидации и автогенерацией хуков; если вы уже на Redux, отдельная библиотека для данных не нужна.
Честная оценка. RTK стоит взять, когда команда большая и нужен единый протокол изменений; когда нужна отладка по истории действий в проде (лог действий в Sentry — мощнейший инструмент); когда логика переходов сложная. Он избыточен, когда приложение небольшое, а всё «глобальное» на деле — серверный кэш. Цена: около 13–15 КБ gzip вместе с react-redux, что для SPA несущественно, но для лендинга заметно.
Zustand: минимум церемоний
Zustand — тот же паттерн «внешний стор + селектор», собранный в 1,2 КБ gzip. Никаких провайдеров и экшенов: стор — это хук.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
type CartState = {
items: Record<string, { price: number; qty: number }>;
add: (id: string, price: number) => void;
remove: (id: string) => void;
};
export const useCart = create<CartState>()(
devtools(
persist(
immer((set) => ({
items: {},
add: (id, price) =>
set((s) => { // immer: пишем «мутацию», получаем снимок
const item = s.items[id];
if (item) item.qty += 1;
else s.items[id] = { price, qty: 1 };
}),
remove: (id) => set((s) => { delete s.items[id]; }),
})),
{ name: 'cart', version: 1, partialize: (s) => ({ items: s.items }) },
),
{ name: 'cart-store' },
),
);
Правило использования одно, и его нарушают все:
const { items } = useCart(); // ❌ подписка на весь стор: рендер на всё
const items = useCart((s) => s.items); // ✅ узкий селектор
// ❌ селектор строит новый объект → Object.is всегда false → рендер каждый раз
const { add, remove } = useCart((s) => ({ add: s.add, remove: s.remove }));
// ✅ либо по одному действию (ссылки стабильны — созданы один раз при инициализации)
const add = useCart((s) => s.add);
// ✅ либо явное поверхностное сравнение
import { useShallow } from 'zustand/react/shallow';
const { add, remove } = useCart(useShallow((s) => ({ add: s.add, remove: s.remove })));
Производные значения не хранят в сторе — их вычисляют в селекторе, а тяжёлые кэшируют через createSelector из reselect (он прекрасно работает и без Redux) или useMemo.
Транзиентные обновления — приём для очень частых событий (курсор, скролл, перетаскивание): подписываемся вручную и пишем прямо в DOM, не вызывая рендер вообще.
function Cursor() {
const ref = useRef<HTMLDivElement>(null);
// subscribe возвращает функцию отписки — она же cleanup эффекта.
// 60 обновлений в секунду и НОЛЬ рендеров React.
useEffect(() => useCursor.subscribe((s) => {
if (ref.current) ref.current.style.transform = `translate(${s.x}px, ${s.y}px)`;
}), []);
return <div ref={ref} className="cursor" />;
}
Большой стор режут на слайсы — функции (set, get) => ({...}), которые собираются в один объект; паттерн описан в документации Zustand и в разборе tkdodo.eu/blog/working-with-zustand.
Ограничение, о котором молчат. Глобальный create создаёт модульный синглтон. Для SPA это нормально, для SSR — источник утечки данных между запросами разных пользователей; решение — фабрика стора на запрос плюс Context для прокидывания инстанса (см. ниже).
Атомы: Jotai и подход «снизу вверх»
Redux и Zustand строят состояние сверху вниз: есть большой объект, из него селекторами вырезают куски. Jotai (и его предок Recoil) идёт снизу вверх: единица состояния — атом, крошечное независимое значение, а составные значения собираются из атомов как формулы в электронной таблице.
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const firstNameAtom = atom('Ада');
export const lastNameAtom = atom('Лавлейс');
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
// Производный атом: зависимости определяются автоматически по вызовам get
export const fullNameAtom = atom((get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`);
// Асинхронное чтение интегрируется с Suspense из коробки
export const userAtom = atom(async (get) => {
const res = await fetch(`/api/users/${get(userIdAtom)}`);
return res.json() as Promise<User>;
});
function Greeting() {
const fullName = useAtomValue(fullNameAtom); // только чтение
const setFirst = useSetAtom(firstNameAtom); // только запись — рендера нет вообще
return <h1 onClick={() => setFirst('Грейс')}>{fullName}</h1>;
}
Ключевое отличие от селекторов: подписка выводится из графа зависимостей, а не пишется руками. Компонент, читающий themeAtom, физически не может быть разбужен изменением firstNameAtom — между ними нет ребра. Это устраняет класс ошибок «забыл сузить селектор». Цена: состояние размазано по множеству мелких объектов, отладка «кто и когда изменил» сложнее, чем в Redux с его логом действий. Jotai силён в редакторах, конструкторах и формах-конфигураторах — там, где много независимых ячеек с перекрёстными вычислениями.
Сигналы: реактивность без реконсиляции
Сигнал — та же идея атома, доведённая до конца: значение, которое само знает своих читателей. При изменении оно будит не компонент, а конкретные вычисления и конкретные обновления DOM.
Свои сигналы за 40 строк
Механизм называется автоматическим отслеживанием зависимостей и держится на одной глобальной переменной «кто сейчас читает».
type Reaction = { run: () => void; deps: Set<Set<Reaction>> };
let active: Reaction | null = null; // тот, кто сейчас выполняется
export function signal<T>(initial: T) {
let value = initial;
const subs = new Set<Reaction>();
return {
get(): T {
if (active) {
subs.add(active); // сигнал запомнил читателя
active.deps.add(subs); // читатель запомнил, откуда отписываться
}
return value;
},
set(next: T) {
if (Object.is(next, value)) return; // равные значения не будят никого
value = next;
for (const r of [...subs]) r.run();
},
};
}
export function effect(fn: () => void) {
const reaction: Reaction = {
deps: new Set(),
run() {
// Зависимости пересобираются на каждом прогоне: ветвление внутри fn
// меняет набор подписок, поэтому старые связи снимаем
for (const s of reaction.deps) s.delete(reaction);
reaction.deps.clear();
const prev = active;
active = reaction;
try { fn(); } finally { active = prev; }
},
};
reaction.run();
return () => { for (const s of reaction.deps) s.delete(reaction); };
}
export function computed<T>(fn: () => T) {
const s = signal<T>(undefined as T);
effect(() => s.set(fn())); // упрощение: настоящие computed ленивы
return { get: s.get };
}
Три десятка строк — и у вас работающая реактивность:
const first = signal('Ада');
const last = signal('Лавлейс');
const full = computed(() => `${first.get()} ${last.get()}`);
effect(() => { document.title = full.get(); }); // подписался на full → на first и last
last.set('Байрон'); // document.title обновился, больше ничего не выполнялось
Реальные реализации (Preact Signals, Solid, Angular, Vue) отличаются двумя вещами: их computed ленивы — при изменении источника они лишь помечаются «грязными», а пересчитываются при следующем чтении; и эффекты планируются пакетом в микрозадаче, чтобы синхронная серия записей дала один прогон. Плюс они обрывают распространение, если пересчитанное значение совпало с прежним, — это спасает от «водопада эффектов».
Сигналы во фреймворках
- Solid —
createSignal/createMemo/createEffect. Компонент выполняется ровно один раз: JSX компилируется в прямые DOM-операции, привязанные к сигналам, виртуального DOM нет вообще (intro to reactivity). - Vue 3 —
ref/reactive/computed/watchEffect, тот же механизм на Proxy (Reactivity in Depth). - Svelte 5 — руны
$state/$derived/$effect: сигналы, встроенные в компилятор. - Angular 16+ —
signal()/computed()/effect()вместо обхода всего дерева Zone.js (angular.dev/guide/signals). - Preact Signals —
@preact/signals-reactработает и в React, привязывая текстовые узлы напрямую.
Сравнение самих фреймворков и того, где какая модель выигрывает, — в отдельной статье трека.
Почему в React сигналов нет
Модель React — «UI это функция от состояния, компонент перерисовывается целиком, разницу считает реконсиляция». Сигналы — «UI это граф вычислений, обновляем точку». Смешивать их означает получить два конкурирующих источника истины и сломать конкурентный рендеринг (useTransition, Suspense, серверные компоненты), который построен на предположении, что рендер — чистая функция, которую можно прервать и запустить заново.
Ответ команды React — React Compiler: он анализирует код и сам расставляет мемоизацию, снимая с разработчика useMemo/useCallback. Это не сигналы: гранулярность остаётся на уровне компонента, но лишние рендеры отсекаются автоматически. Практический вывод: в React 19+ не героизируйте ручную мемоизацию — включите компилятор и меряйте.
Машины состояний: когда переходы важнее данных
Для сценариев с реальным жизненным циклом — многошаговая форма, платёж, загрузка файла с ретраями — редьюсер эволюционирует в явный конечный автомат.
Главная ценность — не библиотека, а дисциплина: вы перечисляете состояния и разрешённые переходы, и «двойная отправка формы» перестаёт быть багом, который чинят флагом isSubmitting. Её просто нельзя выразить.
// XState v5: та же машина декларативно
export const submitMachine = setup({
types: {} as { context: { attempts: number }; events: { type: 'SUBMIT' } | { type: 'RETRY' } },
actions: { count: assign({ attempts: ({ context }) => context.attempts + 1 }) },
}).createMachine({
initial: 'draft',
context: { attempts: 0 },
states: {
draft: { on: { SUBMIT: 'submitting' } },
submitting: { entry: 'count', invoke: { src: 'save', onDone: 'success', onError: 'failure' } },
failure: {
on: { RETRY: [
{ target: 'submitting', guard: ({ context }) => context.attempts < 3 },
{ target: 'dead' },
] },
},
success: { type: 'final' },
dead: { type: 'final' },
},
});
XState (5–15 КБ) окупается на действительно сложных сценариях: платежи, онбординг, видеоплееры, редакторы. Для трёх состояний он избыточен — хватит union-типа из раздела про useReducer. Документация и визуальный редактор: stately.ai/docs.
Состояние, которое живёт не в памяти
URL — самый недооценённый стор
Фильтры, поиск, страница, открытая вкладка, id выбранной сущности — состояние навигации, и его дом — адресная строка. Тогда бесплатно работают «поделиться ссылкой», кнопка «назад», восстановление после перезагрузки и индексация.
import { useSearchParams } from 'react-router-dom';
function useQueryFilter() {
const [params, setParams] = useSearchParams();
const setQuery = (next: string) =>
setParams((prev) => {
const p = new URLSearchParams(prev);
next ? p.set('q', next) : p.delete('q');
p.delete('page'); // смена запроса сбрасывает пагинацию
return p;
}, { replace: true }); // не засоряем историю на каждое нажатие
return { q: params.get('q') ?? '', page: Number(params.get('page') ?? 1), setQuery };
}
Для типобезопасной работы с параметрами есть nuqs — хуки с сериализацией и дебаунсом. Подробнее про роутинг — в статье о стратегиях рендеринга.
Хранилища и гидратация
localStorage синхронен и блокирует главный поток — не пишите в него на каждое нажатие, дебаунсите. Токены доступа в localStorage — прямой путь к краже через XSS; им место в HttpOnly-cookie.
Отдельная продовая мина — модульный синглтон стора при SSR: на сервере модуль загружается один раз на процесс, значит стор общий для всех запросов, и пользователь A увидит корзину пользователя B.
export const store = createStore(initialState); // ❌ на сервере это утечка между запросами
// ✅ фабрика на запрос + инстанс через Context
export function StoreProvider({ children, preloaded }: Props) {
const storeRef = useRef<Store | null>(null); // один инстанс на монтирование
if (storeRef.current === null) storeRef.current = createStore(preloaded);
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
}
Второе правило гидратации: первый клиентский рендер обязан совпасть с серверным. Поэтому у useSyncExternalStore есть третий аргумент getServerSnapshot, а состояние из localStorage нельзя читать в инициализаторе useState на SSR-странице — будет ошибка гидратации. Читайте его в эффекте или через persist со skipHydration.
Почему тормозит и как измерять
Состояние — источник примерно половины проблем с отзывчивостью. Причины по частоте:
1. Слишком широкая подписка. Компонент подписан на весь стор или контекст, а использует одно поле. Симптом: при простом действии в профайлере загорается пол-экрана. Лечение: селекторы, разделение контекстов, useShallow.
2. Селектор возвращает новый объект. useSelector(s => ({ a: s.a })) или .filter() прямо в селекторе создают новую ссылку → сравнение Object.is всегда ложно → рендер на каждое действие в приложении, даже чужое. Лечение: createSelector, поверхностное сравнение.
3. Высокочастотные обновления в глобальном сторе. Ввод текста, скролл, перетаскивание, поток котировок по WebSocket. 60 обновлений в секунду × рендер таблицы = зависший интерфейс. Лечение: локальное состояние, транзиентные подписки, дебаунс, useDeferredValue для дорогого производного рендера.
const [query, setQuery] = useState('');
// Поле реагирует мгновенно; тяжёлый список догоняет с отставанием,
// и его рендер React прерывает при новом вводе
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => search(items, deferredQuery), [items, deferredQuery]);
4. Нарушенная иммутабельность. state.items.push(x) вне Immer меняет объект по месту, ссылка остаётся прежней, подписчик не видит изменения — интерфейс «не обновляется, пока не кликнешь по чему-то ещё». RTK ловит это проверкой в dev-режиме.
Инструментарий
- React DevTools → Profiler. Запишите взаимодействие и посмотрите flamegraph: серые компоненты не рендерились, цветные — рендерились, вкладка Ranked показывает самых дорогих. В настройках включите «Highlight updates when components render»: на экране появятся рамки вокруг перерисовавшихся узлов, и лишние подписки видно невооружённым глазом.
- Компонент
<Profiler>— программный замер в CI или в проде на выборке пользователей:
<Profiler id="cart" onRender={(id, phase, actualDuration, baseDuration) => {
// actualDuration — сколько заняло реально, baseDuration — сколько заняло бы без мемоизации
if (actualDuration > 16) analytics.track('slow-render', { id, phase, actualDuration });
}}>
<Cart />
</Profiler>
- Chrome DevTools → Performance. Ищите длинные задачи (> 50 мс, помечены красным углом): именно они портят INP — метрику отзывчивости из Core Web Vitals с порогом «хорошо» 200 мс.
// Длинные задачи в проде
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) analytics.track('long-task', { duration: entry.duration });
}
}).observe({ type: 'longtask', buffered: true });
// И реальный INP от живых пользователей
import { onINP } from 'web-vitals';
onINP(({ value, attribution }) => analytics.track('inp', { value, target: attribution.interactionTarget }));
performance.markвокруг подозрительного действия — в таймлайне будет виден ровно ваш диспатч, а не «что-то внутри React».
Полный разбор Core Web Vitals и бюджетов — в статье о производительности; каноническое описание метрики — web.dev/articles/inp.
Как выбрать
это кэш, а не стор"] B -- нет --> D{"Должно переживать перезагрузку
и попадать в ссылку?"} D -- да --> E["URL: searchParams"] D -- нет --> F{"Нужно больше чем
одному компоненту?"} F -- нет --> G["useState / useReducer
рядом с местом использования"] F -- да --> H{"Меняется редко?
тема, локаль, юзер"} H -- да --> I["Context с разделением
данных и действий"] H -- нет --> J{"Много независимых
перекрёстных ячеек?"} J -- да --> K["Jotai или сигналы"] J -- нет --> L{"Нужен лог действий,
большая команда,
сложные переходы?"} L -- да --> M["Redux Toolkit"] L -- нет --> N["Zustand"]
| Инструмент | Размер gzip | Сильная сторона | Слабая сторона |
|---|---|---|---|
useState / useReducer |
0 | нулевая цена, локальность | не шарится, теряется при размонтировании |
| Context | 0 | DI без библиотек | нет селекторов, широкая подписка |
| Zustand | ~1,2 КБ | минимум кода, нет провайдера | легко забыть селектор; синглтон и SSR |
| Jotai | ~3 КБ | автоподписка по графу, Suspense | состояние размазано, обзор сложнее |
| Redux Toolkit + react-redux | ~13 КБ | предсказуемость, DevTools, RTK Query | многословность, порог входа |
| XState | ~15 КБ | явные переходы, визуализация | избыточен для простых случаев |
| Сигналы (Solid, Vue, Angular) | часть фреймворка | точечный DOM без diff | в React неродная модель |
Типичные ошибки
- Всё глобальное. Модалка одного экрана в глобальном сторе. Состояние должно жить максимально близко к месту использования — это и производительность, и удаляемость кода.
- Серверные данные в редьюсерах. Ручные
loading/error/dataна каждый запрос — переизобретение React Query, только с гонками и без дедупликации. - Дублирование производных значений.
itemsиfilteredItemsв одном сторе неизбежно разъедутся. - Мутация состояния. Вне Immer любое
state.arr.push()ломает сравнение по ссылке. - Селектор, создающий объект. Самый частый ответ на вопрос «почему всё рендерится».
- Один жирный контекст на всё приложение. Тема, юзер, корзина и настройки в одном
value— обновление любого поля будит всех. useEffectдля синхронизации состояния с состоянием.useEffect(() => setB(f(a)), [a])— лишний рендер и рассинхрон на кадр; вычисляйтеbпрямо при рендере.- Хранение несериализуемого. Функции, классы,
Date,Mapв Redux ломают DevTools и персистентность. - Глобальный синглтон при SSR. Утечка состояния между запросами разных пользователей.
- Оптимизация без замера.
useMemoвокруг сложения двух чисел стоит дороже сложения. Сначала Profiler, потом мемоизация.
Мини-итог и чек-лист
Управление состоянием — сначала классификация, и только потом библиотека. Спросите: чьи это данные, как долго они живут, кто на них реагирует. Серверное — в кэш запросов. Навигационное — в URL. Локальное — рядом. Редко меняющееся глобальное — в Context. Часто меняющееся глобальное — во внешний стор с селекторами. Сложные переходы — в конечный автомат.
Перед добавлением библиотеки в проект:
- проверил, что это не серверный кэш и не состояние навигации;
- попробовал поднять состояние и применить приём с
children; - знаю, какое поле кем читается, — то есть смогу написать узкие селекторы;
- понимаю поведение при SSR: фабрика на запрос,
getServerSnapshot, гидратация; - у высокочастотных обновлений есть план: дебаунс, транзиентная подписка или локальный стейт;
- снял профиль до и после — есть цифры, а не ощущения.
Источники, которые стоит прочитать целиком:
- Managing State — react.dev и useSyncExternalStore.
- Mark Erikson. Why React Context is Not a “State Management” Tool.
- Redux Style Guide и Redux Toolkit.
- Zustand docs, Dominik Dorfmeister. Working with Zustand.
- Jotai — атомарная модель и рецепты.
- Preact: Introducing Signals — лучшее объяснение fine-grained реактивности.
- Kent C. Dodds. Application State Management with React.
- Stately / XState docs — конечные автоматы в UI.
Что дальше
Мы намеренно вынесли за скобки самый объёмный вид состояния — данные с сервера. Их нельзя «просто положить в стор»: они устаревают, приходят с задержкой, дублируются между экранами и требуют инвалидации. Следующая статья — Работа с данными: fetch, React Query, кэширование, оптимистичные обновления — про то, как выглядит правильный слой серверного кэша и почему он снимает большую часть нагрузки с клиентского состояния.