Состояние в React-приложении бывает разным, и главный навык на собесе: сказать, где какое хранить, а не назвать любимую библиотеку.
Четыре вида состояния
- Локальное UI-состояние: открыт ли дропдаун, текст в поле. Живёт в
useStateу компонента. - Общее клиентское состояние: тема, текущий пользователь, корзина. Нужно многим компонентам в разных ветках дерева.
- Серверное состояние: данные с бэкенда. Это кеш чужих данных: он устаревает, его надо перезапрашивать, дедуплицировать запросы, показывать загрузку и ошибки.
- Состояние в URL: фильтры, номер страницы, открытая вкладка. Его можно скопировать ссылкой и пережить перезагрузку.
Типичная ошибка: класть всё в один глобальный стор, включая ответы API и флаг открытой модалки.
Сначала без библиотек
Если состояние нужно двум соседям, поднимите его к ближайшему общему родителю. Если пропсы приходится тащить через 4-5 уровней (prop drilling), сначала попробуйте композицию: передайте готовый элемент через children, и промежуточные слои перестанут знать про данные.
// вместо <Layout user={user}> ... <Header user={user}> ... <Avatar user={user}>
<Layout header={<Header avatar={<Avatar user={user} />} />} />
Context: транспорт, а не стор
Context доставляет значение вниз по дереву без пропсов. В React 19 провайдером служит сам контекст:
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
<Page />
</ThemeContext>
);
}
function Button() {
const theme = use(ThemeContext); // или useContext(ThemeContext)
return <button className={theme}>OK</button>;
}
Запись <ThemeContext.Provider> тоже работает, но в новых проектах пишут короче.
Ключевое правило: когда value провайдера меняется (сравнение через Object.is), перерисовываются все компоненты, которые читают этот контекст, даже если им нужна только часть значения. React.memo на потребителе не спасает: подписка на контекст пробивает memo.
Отсюда частые ошибки:
// новый объект на каждый рендер App: все потребители рендерятся всегда
<AuthContext value={{ user, login, logout }}>
Лечится так:
- мемоизировать значение
useMemo(или довериться React Compiler, если он включён); - разделить контексты: данные отдельно, функции-сеттеры отдельно (сеттеры стабильны и почти никогда не меняются);
- не класть в контекст часто меняющееся значение (позиция мыши, ввод в поле), которое читает пол-приложения.
Context хорош для редко меняющихся вещей: тема, локаль, текущий пользователь, зависимости (клиент API).
Внешние сторы
Когда общего состояния много и оно часто меняется, берут стор вне React. Компонент подписывается на срез и перерисовывается только при изменении этого среза.
Redux Toolkit (RTK): единый стор, изменения только через экшены и редьюсеры. createSlice генерирует экшены, внутри редьюсера можно «мутировать» черновик, потому что RTK использует Immer. Сильные стороны: предсказуемость, DevTools с историей экшенов, middleware. RTK Query закрывает серверное состояние.
const cart = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
added(state, action) { state.items.push(action.payload); }, // Immer
},
});
const count = useSelector((s) => s.cart.items.length);
Zustand: стор как хук, без провайдера и бойлерплейта.
const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}));
const count = useCart((s) => s.items.length); // подписка на срез
Атомарные (Jotai, Recoil в прошлом): состояние разбито на маленькие атомы, компонент подписан только на свои атомы.
MobX: наблюдаемые объекты, компоненты-наблюдатели перерисовываются при изменении прочитанных полей. Мутабельный стиль.
Под капотом корректные внешние сторы используют useSyncExternalStore: он даёт подписку без «разрывов» (tearing) в конкурентном рендере.
Серверное состояние отдельно
TanStack Query, RTK Query, SWR решают задачи кеша: ключ запроса, дедупликация, повторный запрос при фокусе окна, инвалидация после мутации, ретраи. В TanStack Query v5 по умолчанию staleTime: 0 (данные сразу считаются устаревшими) и gcTime 5 минут (сколько неиспользуемый кеш живёт в памяти; раньше назывался cacheTime).
Писать загрузку через useEffect + useState + глобальный стор можно, но придётся заново изобрести гонки, отмену и кеш.
Частые ошибки
- Context с объектом-литералом в
valueи десятком потребителей. - Ожидание, что
React.memoостановит перерисовку от контекста. - Селектор, который возвращает новый объект:
useStore((s) => ({ a: s.a, b: s.b }))в Zustand v5 безuseShallowвызывает лишние рендеры и даже бесконечный цикл. - Копия данных сервера в Redux, а потом ручная синхронизация.
- Глобальный стор для формы, которая живёт на одном экране.
Что спросят на собесе
- Когда хватит
useStateи подъёма состояния, а когда нужен Context или стор? - Почему Context вызывает лишние перерисовки и как это исправить?
- Чем Redux Toolkit отличается от Zustand, и что выбрать для нового проекта?
- Что такое серверное состояние и почему его не держат в Redux?
- Зачем нужен
useSyncExternalStore?