Подходы к state management в React
- вторник, 13 января 2026 г. в 00:00:06
У React-разработчика две беды:
Целевые данные изменились, а ререндера не произошло.
Целевые данные не изменились, а ререндер произошёл.
Есть много способов подружить данные с UI. В рамках этой статьи я пройдусь по популярным подходам: разберём, как устроены подписки, почему происходят лишние ререндеры, и когда какой инструмент подходит лучше. Выбор библиотек основан только на личном опыте: описал то, с чем сталкивался лично. В целом, все библиотеки из списка крайне популярны в React сообществе, просто если вашей любимой библиотеки здесь нет - значит я еще с ней не работал, буду рад рекомендациям.
Итак, как мы знаем, React по своей сути не реактивен (у разработчиков было чувство юмора), то есть при изменении переменной интерфейс не обновляется автоматически. UI обновляется тогда, когда React видит изменение через state/props/context или через механизм внешней подписки (например, useSyncExternalStore).
Перейдем к обзору существующих подходов:
Локальный UI state одного компонента / виджета: формы, табы, другая ui мелочь.
Shared state внутри одного поддерева: несколько компонентов должны видеть и менять одно состояние.
useState и useReducer дают простой контракт: обновился state → React планирует обновление → компонент rerender.
Атомарность по умолчанию — компонентная: изменился state → rerender всего компонента (дочерние можно оптимизировать через React.memo или другие паттерны).
Context решает проблему prop drilling, но имеет важное свойство:
все консьюмеры ререндерятся при смене value у Provider, если ссылка на value изменилась (===).
Самый частый источник лишних ререндеров — это не “плохой Context”, а то, что value создаётся заново на каждый Provider.
Provider хранит state (useState/useReducer)
value мемоизируем через useMemo
Пример (Context + useState)
// file: todos-context.tsx
import React, { createContext, useContext, useMemo, useState, ReactNode } from 'react';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodosContextValue {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
}
const TodosContext = createContext<TodosContextValue | null>(null);
export function TodosProvider({ children }: { children: ReactNode }) {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
};
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const value = useMemo<TodosContextValue>(
() => ({ todos, addTodo, toggleTodo }),
[todos]
);
return <TodosContext.Provider value={value}>{children}</TodosContext.Provider>;
}
export function useTodos() {
const ctx = useContext(TodosContext);
if (!ctx) throw new Error('useTodos must be used within TodosProvider');
return ctx;
}
// file: TodoList.tsx
import React from 'react';
import { useTodos } from './todos-context';
export function TodoList() {
const { todos, addTodo, toggleTodo } = useTodos();
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</label>
</li>
))}
</ul>
<button onClick={() => addTodo('Новая задача')}>Добавить</button>
</div>
);
}
// file: App.tsx
import React from 'react';
import { TodosProvider } from './todos-context';
import { TodoList } from './TodoList';
export default function App() {
return (
<TodosProvider>
<TodoList />
</TodosProvider>
);
}
0 external dependencies.
Отлично для локального UI state.
Provider = естественная область жизни state (unmount → state сбросился).
Context без селекторов: изменение value → rerender всех consumers.
В больших приложениях часто приходит необходимость в selectors/DevTools/middleware.
Частые обновления (например, realtime) + много consumers.
Нужно удобно дебажить историю изменений (time travel, action log).
Но если все работает из коробки, почему при поиске по npm.js запроса "react state manager" у нас 1000+ библиотек?
С ростом масштаба приложения при использовании коробочных средств есть риск получить или много лишних ререндеров или огромный оверинжиниринг там, где его легко избежать, используя библиотеки.
Контекст и стейт хорошо работают на малых объемах данных. Но если у вас, скажем, большое приложение, где состояние изменяется каждую секунду в разных частях, контекст может привести к значительной нагрузке – множество компонентов будут перерисовываться даже без необходимости.
В React нет встроенной системы селекторов или вычисления зависимостей: либо вводить дробление контекстов, либо страдать от лишних рендеров. Также нет DevTools, показывающих изменения контекста (React DevTools умеет показывать текущее значение контекста, но не хронологию). Трассировать изменения через консоль или debugger сложнее, особенно если много компонентов писают в состояние. Нет и средств типа time-travel debugging.
Redux — централизованный иммутабельный store с однонаправленным потоком данных, обеспечивающий предсказуемость, воспроизводимость и мощные DevTools. Компоненты подписываются на конкретные срезы состояния через селекторы и обновляются только при изменении ссылок. Решение отлично масштабируется для больших команд и сложных доменов, но остаётся более многословным и часто избыточным для небольших проектов.
Большой продукт, много команд, много бизнес-событий.
Нужна строгая дисциплина изменения state, action log и DevTools.
Нужна предсказуемость: action -> reducer -> next state.
Redux — централизованный store + “one-way data flow”. В React обычно используется связка Redux Toolkit + React-Redux.
Ключевая вещь для ререндеров: React-Redux подписывает компоненты на store через selectors.
Компонент ререндерится, если результат useSelector(selector) изменился (по умолчанию сравнение значений, часто ===, плюс есть shallowEqual и кастомные сравнения).
UI event
↓
dispatch(action)
↓
reducer → nextState
↓
useSelector runs selectors
↓
if selected slice changed → rerender
// file: store.ts
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.push({ id: Date.now(), text: action.payload, completed: false });
},
toggleTodo: (state, action: PayloadAction<number>) => {
const todo = state.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
},
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export const store = configureStore({
reducer: {
todos: todosSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// file: TodoList.tsx
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
import { addTodo, toggleTodo } from './store';
export function TodoList() {
const todos = useSelector((s: RootState) => s.todos);
const dispatch = useDispatch<AppDispatch>();
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodo(todo.id))}
/>
{todo.text}
</label>
</li>
))}
</ul>
<button onClick={() => dispatch(addTodo('Новая задача'))}>Добавить</button>
</div>
);
}
// file: App.tsx
import React from 'react';
import { Provider } from 'react-redux';
import { store } from './store';
import { TodoList } from './TodoList';
export default function App() {
return (
<Provider store={store}>
<TodoList />
</Provider>
);
}
Развитые DevTools, action log, time travel.
Стандартизированный подход: удобно для code review и масштабирования команды.
Selector модель позволяет контролировать атомарность каждого ререндера.
Больше boilerplate и структуры (даже с Toolkit).
Для маленьких приложений может быть overkill.
Маленький проект, локальный UI state — проще решить встроенными средствами.
Сильно интерактивные сценарии с частыми мелкими мутациями, где нужно просто менять объект.
Zustand — лёгкий глобальный store на хуках с селекторной подпиской: компонент обновляется только при изменении выбранной части состояния. Библиотека почти не навязывает архитектуру, что делает её удобной и быстрой в разработке, но в больших командах требует дисциплины, иначе структура стора быстро становится неуправляемой.
Нужен global state, но без характерного для Redux boilerplate.
Хочется selector-based подписки и минимального API.
Проект небольшой / средний или команда готова устанавливать конвенции.
Zustand представляет собой легковесный store, который возвращает кастомный hook. Компоненты подписываются через selectors.
Важно писать селекторы так, чтобы они не возвращали новый объект каждый раз, иначе - лишние ререндеры.
// file: useTodoStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (text) =>
set((s) => ({
todos: [...s.todos, { id: Date.now(), text, completed: false }],
})),
toggleTodo: (id) =>
set((s) => ({
todos: s.todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
})),
}));
export const useTodoActions = () =>
useTodoStore(
(s) => ({ addTodo: s.addTodo, toggleTodo: s.toggleTodo }),
shallow
);
// file: TodoList.tsx
import React from 'react';
import { useTodoStore, useTodoActions } from './useTodoStore';
export function TodoList() {
const todos = useTodoStore((s) => s.todos);
const { addTodo, toggleTodo } = useTodoActions();
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</label>
</li>
))}
</ul>
<button onClick={() => addTodo('Новая задача')}>Добавить</button>
</div>
);
}
Минимализм и хороший DX.
Selector-based подписки → контроль rerender.
Можно держать несколько store (feature-based), не навязывает архитектуру.
Архитектура на совести команды: без соглашений легко получить кашу.
Меньше готовой инфраструктуры, чем у Redux (middleware, conventions).
Проект с жёсткими требованиями к аудиту изменений и единым процессом через actions.
Команда не готова договориться о naming / structure conventions.
Jotai разбивает состояние на атомы — минимальные единицы подписки, включая вычисляемые атомы с графом зависимостей. Компоненты обновляются только при изменении используемых атомов, что даёт очень точечные ререндеры без ручных селекторов. Подход хорошо ложится на component-first архитектуру, но в больших приложениях усложняет управление связями между атомами.
Много независимых кусочков state и хочется hooks, но глобально.
Нужна минимальная единица подписки — atoms.
Много derived state: удобно собирать граф зависимостей.
Jotai предлагает atomic state: каждый atom — отдельная точка подписки. Обновление atom → rerender только тех компонентов, которые используют этот atom.
// file: atoms.ts
import { atom } from 'jotai';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export const todosAtom = atom<Todo[]>([]);
// file: TodoList.tsx
import React from 'react';
import { useAtom } from 'jotai';
import { todosAtom } from './atoms';
export function TodoList() {
const [todos, setTodos] = useAtom(todosAtom);
const addTodo = () => {
setTodos((prev) => [
...prev,
{ id: Date.now(), text: 'Новая задача', completed: false },
]);
};
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</label>
</li>
))}
</ul>
<button onClick={addTodo}>Добавить</button>
</div>
);
}
Fine-grained подписки на уровне atom.
Derived atoms помогают строить dependency graph.
Очень component-first API.
В больших проектах нужно следить за структурой atom (naming, grouping, ownership).
Derived state легко превратить в сложный граф, если не контролировать.
Если нужен единый action log и строгая процессность изменений (скорее Redux).
MobX реализует реактивную модель с наблюдаемыми объектами и прямыми мутациями состояния. Зависимости отслеживаются автоматически на уровне свойств, поэтому обновляются только реально затронутые компоненты. Это даёт высокую производительность и удобство в сложных зависимых состояниях, но снижает явность потока данных и усложняет отладку для React-разработчиков.
Много derived state и сложные зависимости.
Хочется писать мутациями (imperative updates), а UI обновлялся автоматически.
Нужна максимально fine-grained реактивность на уровне полей.
MobX — это dependency tracking: компоненты (через observer) автоматически “подписываются” на observables, которые они прочитали во время render.
Вместо “селектор → сравнение результата” здесь работает модель “прочитал поле → подписался на поле”.
render (observer component)
↓
reads observable fields → tracking deps
↓
mutation of observable field
↓
notify exact observers → rerender
// file: todoStore.ts
import { makeAutoObservable } from 'mobx';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export class TodoStore {
todos: Todo[] = [];
constructor() {
makeAutoObservable(this);
}
addTodo(text: string) {
this.todos.push({ id: Date.now(), text, completed: false });
}
toggleTodo(id: number) {
const todo = this.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}
}
export const todoStore = new TodoStore();
// file: TodoList.tsx
import React from 'react';
import { observer } from 'mobx-react-lite';
import { todoStore } from './todoStore';
export const TodoList = observer(() => {
const store = todoStore;
return (
<div>
<ul>
{store.todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => store.toggleTodo(todo.id)}
/>
{todo.text}
</label>
</li>
))}
</ul>
<button onClick={() => store.addTodo('Новая задача')}>Добавить</button>
</div>
);
});
Очень точечные обновления благодаря dependency tracking.
Удобно для сложного derived state.
Меньше boilerplate: мутации напрямую.
Нужно понимать, как формируются подписки (что именно ушло в render).
Debugging может быть непривычным после action-based систем.
Команда хочет строгое, стандартизированное изменение state через actions.
Нужен простой mental model без реактивной магии.
Valtio использует Proxy для реактивного состояния с прямыми мутациями и property-level подписками через useSnapshot. Компоненты перерисовываются только при изменении прочитанных свойств, что делает библиотеку удобной для highly-interactive UI. При этом состояние по умолчанию глобальное, экосистема менее зрелая, и изоляция экземпляров требует дополнительных паттернов.
Очень интерактивный UI с частыми мелкими мутациями (drag&drop, realtime dashboards, canvas-like интерфейсы).
Хочется писать state как обычный mutable object.
Нужны fine-grained обновления “по прочитанным полям”, но проще, чем MobX.
Valtio строится на Proxy. Компонент получает snapshot через useSnapshot(state) и автоматически подписывается на свойства, которые он прочитал во время render.
// file: state.ts
import { proxy } from 'valtio';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export const state = proxy<{ todos: Todo[] }>({
todos: [],
});
export function addTodo(text: string) {
state.todos.push({ id: Date.now(), text, completed: false });
}
export function toggleTodo(id: number) {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}
// file: TodoList.tsx
import React from 'react';
import { useSnapshot } from 'valtio';
import { addTodo, state, toggleTodo } from './state';
export function TodoList() {
const snap = useSnapshot(state);
return (
<div>
<ul>
{snap.todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</label>
</li>
))}
</ul>
<button onClick={() => addTodo('Новая задача')}>Добавить</button>
</div>
);
}
Очень простой mutable API.
Fine-grained обновления по property access tracking.
Хорошо ложится на highly-interactive UI.
Меньше экосистемы и “стандарта” вокруг архитектуры.
По умолчанию proxy часто используется как singleton (export из модуля) — нужно помнить про scoping.
Нужна строгая модульность/изоляция инстансов без дополнительных паттернов.
Большая команда без договорённостей о scoping и ownership state.
Подход | Атомарность подписки | Boilerplate | Debug/DevTools | Team scaling | SSR/изоляция |
|---|---|---|---|---|---|
useState/useReducer | компонент | минимальная | базовая | низкое | ок |
Context | все consumers по | минимальная | базовая | средне | oк (Provider per request) |
Redux + React-Redux | selector slice | средняя | отличная | отличное | нужен per-request store |
Zustand | selector slice | низкая | средняя | высокое | нужен per-request store/instance |
Jotai | atom | низкая | средняя | высокое | нужен provider scoping |
MobX | поле/observable | низкая | средняя | отличное | нужен per-request instance |
Valtio | property access | низкая | базовая/средняя | высокое | нужен per-request instance |
State management в React — это в первую очередь управление подписками и атомарностью обновлений, чтобы каждый компонент получал ровно столько данных, сколько ему нужно.
А уже вопрос с помощью чего это реализовать вторичен. Но все таки вот тезисы, над чем подумать, выбирая метод менеджмента состояния:
Локальный UI state → useState / useReducer.
Shared state в одном поддереве → Context + useReducer/useState, но value обязательно через useMemo.
Нужны selectors и контроль rerender без большого boilerplate → Zustand.
Нужны стандарты, action log, DevTools, team scaling → Redux Toolkit + React-Redux.
Нужен atomic/fine-grained state и component-first стиль → Jotai.
Сложный derived state и dependency tracking → MobX.
Много мелких мутаций и highly-interactive UI → Valtio.
Надеюсь, у вас сложилось понимание, какое решение по менеджменту состояния подходит вашему проекту и вы всегда будете видеть столько ререндеров, сколько ожидаете.
Спасибо за внимание!
Периодически пишу и обсуждаю похожие темы в телеграм-канале.