Frontend-разработка Управление состоянием: локальное, контекст, Redux, Zustand, сигналы
0%

Управление состоянием: локальное, контекст, Redux, Zustand, сигналы

Управление состоянием: локальное, контекст, 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.

Context перерисовывает всё поддерево потребителей, внешний стор — только подписчиков нужного среза

Марк Эриксон, мейнтейнер 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 ленивы — при изменении источника они лишь помечаются «грязными», а пересчитываются при следующем чтении; и эффекты планируются пакетом в микрозадаче, чтобы синхронная серия записей дала один прогон. Плюс они обрывают распространение, если пересчитанное значение совпало с прежним, — это спасает от «водопада эффектов».

Изменение сигнала помечает грязным только зависимую ветку графа

Сигналы во фреймворках

  • SolidcreateSignal / createMemo / createEffect. Компонент выполняется ровно один раз: JSX компилируется в прямые DOM-операции, привязанные к сигналам, виртуального DOM нет вообще (intro to reactivity).
  • Vue 3ref / 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-режиме.

Инструментарий

  1. React DevTools → Profiler. Запишите взаимодействие и посмотрите flamegraph: серые компоненты не рендерились, цветные — рендерились, вкладка Ranked показывает самых дорогих. В настройках включите «Highlight updates when components render»: на экране появятся рамки вокруг перерисовавшихся узлов, и лишние подписки видно невооружённым глазом.
  2. Компонент <Profiler> — программный замер в CI или в проде на выборке пользователей:
<Profiler id="cart" onRender={(id, phase, actualDuration, baseDuration) => {
  // actualDuration — сколько заняло реально, baseDuration — сколько заняло бы без мемоизации
  if (actualDuration > 16) analytics.track('slow-render', { id, phase, actualDuration });
}}>
  <Cart />
</Profiler>
  1. 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 }));
  1. performance.mark вокруг подозрительного действия — в таймлайне будет виден ровно ваш диспатч, а не «что-то внутри React».

Полный разбор Core Web Vitals и бюджетов — в статье о производительности; каноническое описание метрики — web.dev/articles/inp.

Как выбрать

Инструмент Размер 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 неродная модель

Типичные ошибки

  1. Всё глобальное. Модалка одного экрана в глобальном сторе. Состояние должно жить максимально близко к месту использования — это и производительность, и удаляемость кода.
  2. Серверные данные в редьюсерах. Ручные loading/error/data на каждый запрос — переизобретение React Query, только с гонками и без дедупликации.
  3. Дублирование производных значений. items и filteredItems в одном сторе неизбежно разъедутся.
  4. Мутация состояния. Вне Immer любое state.arr.push() ломает сравнение по ссылке.
  5. Селектор, создающий объект. Самый частый ответ на вопрос «почему всё рендерится».
  6. Один жирный контекст на всё приложение. Тема, юзер, корзина и настройки в одном value — обновление любого поля будит всех.
  7. useEffect для синхронизации состояния с состоянием. useEffect(() => setB(f(a)), [a]) — лишний рендер и рассинхрон на кадр; вычисляйте b прямо при рендере.
  8. Хранение несериализуемого. Функции, классы, Date, Map в Redux ломают DevTools и персистентность.
  9. Глобальный синглтон при SSR. Утечка состояния между запросами разных пользователей.
  10. Оптимизация без замера. useMemo вокруг сложения двух чисел стоит дороже сложения. Сначала Profiler, потом мемоизация.

Мини-итог и чек-лист

Управление состоянием — сначала классификация, и только потом библиотека. Спросите: чьи это данные, как долго они живут, кто на них реагирует. Серверное — в кэш запросов. Навигационное — в URL. Локальное — рядом. Редко меняющееся глобальное — в Context. Часто меняющееся глобальное — во внешний стор с селекторами. Сложные переходы — в конечный автомат.

Перед добавлением библиотеки в проект:

  • проверил, что это не серверный кэш и не состояние навигации;
  • попробовал поднять состояние и применить приём с children;
  • знаю, какое поле кем читается, — то есть смогу написать узкие селекторы;
  • понимаю поведение при SSR: фабрика на запрос, getServerSnapshot, гидратация;
  • у высокочастотных обновлений есть план: дебаунс, транзиентная подписка или локальный стейт;
  • снял профиль до и после — есть цифры, а не ощущения.

Источники, которые стоит прочитать целиком:

Что дальше

Мы намеренно вынесли за скобки самый объёмный вид состояния — данные с сервера. Их нельзя «просто положить в стор»: они устаревают, приходят с задержкой, дублируются между экранами и требуют инвалидации. Следующая статья — Работа с данными: fetch, React Query, кэширование, оптимистичные обновления — про то, как выглядит правильный слой серверного кэша и почему он снимает большую часть нагрузки с клиентского состояния.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов