javascript

Подходы к state management в React

  • вторник, 13 января 2026 г. в 00:00:06
https://habr.com/ru/articles/984424/

Две проблемы React-разработчика

У React-разработчика две беды:

  • Целевые данные изменились, а ререндера не произошло.

  • Целевые данные не изменились, а ререндер произошёл.

Есть много способов подружить данные с UI. В рамках этой статьи я пройдусь по популярным подходам: разберём, как устроены подписки, почему происходят лишние ререндеры, и когда какой инструмент подходит лучше. Выбор библиотек основан только на личном опыте: описал то, с чем сталкивался лично. В целом, все библиотеки из списка крайне популярны в React сообществе, просто если вашей любимой библиотеки здесь нет - значит я еще с ней не работал, буду рад рекомендациям.

Итак, как мы знаем, React по своей сути не реактивен (у разработчиков было чувство юмора), то есть при изменении переменной интерфейс не обновляется автоматически. UI обновляется тогда, когда React видит изменение через state/props/context или через механизм внешней подписки (например, useSyncExternalStore).

Перейдем к обзору существующих подходов:

Встроенные решения: useState/useReducer + Context

Когда выбирать

  • Локальный UI state одного компонента / виджета: формы, табы, другая ui мелочь.

  • Shared state внутри одного поддерева: несколько компонентов должны видеть и менять одно состояние.

useState / useReducer

useState и useReducer дают простой контракт: обновился state → React планирует обновление → компонент rerender.

Атомарность по умолчанию — компонентная: изменился state → rerender всего компонента (дочерние можно оптимизировать через React.memo или другие паттерны).

Context

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

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

Пример (Redux Toolkit)

// 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

Zustand — лёгкий глобальный store на хуках с селекторной подпиской: компонент обновляется только при изменении выбранной части состояния. Библиотека почти не навязывает архитектуру, что делает её удобной и быстрой в разработке, но в больших командах требует дисциплины, иначе структура стора быстро становится неуправляемой.

Когда выбирать

  • Нужен global state, но без характерного для Redux boilerplate.

  • Хочется selector-based подписки и минимального API.

  • Проект небольшой / средний или команда готова устанавливать конвенции.

Zustand представляет собой легковесный store, который возвращает кастомный hook. Компоненты подписываются через selectors.

Важно писать селекторы так, чтобы они не возвращали новый объект каждый раз, иначе - лишние ререндеры.

Пример (selectors + shallow)

// 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

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

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

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 по value

минимальная

базовая

средне

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 stateuseState / 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.

Надеюсь, у вас сложилось понимание, какое решение по менеджменту состояния подходит вашему проекту и вы всегда будете видеть столько ререндеров, сколько ожидаете.

Спасибо за внимание!

Периодически пишу и обсуждаю похожие темы в телеграм-канале.