Перейти к содержимому

29. State Managers (Глубокое погружение)

Вы уже знаете базовый обзор подходов. Теперь — детальное сравнение, продвинутые паттерны, DevTools и реальные сценарии применения.


Всё состояние в одном хранилище. Компоненты подписываются на нужные части.

Компонент → dispatch(action) → Reducer/Mutator → Store → React re-render

Плюсы: Предсказуемость, time-travel debugging, единый источник правды. Минусы: Boilerplate (у Redux), централизованная точка отказа.

Состояние разбито на атомы — минимальные единицы, которые могут зависеть от друг друга.

Atom A ←→ Atom B → Selector (computed) → Компонент

Плюсы: Гранулярные обновления, нет лишних ре-рендеров, граф зависимостей. Минусы: Сложность при большом числе атомов, непривычная ментальная модель.

Данные с сервера — это не просто объект в памяти. Это кэш с TTL, состоянием загрузки, ошибками, ретраями.

Network → Cache → Stale/Fresh state → Компонент

Правило: 80% “глобального” стейта в типичном приложении — это данные с сервера. Используйте специализированные инструменты.


Redux DevTools Extension — главная суперсила Redux. Позволяет:

  • Time-travel debugging: перематывать состояние назад/вперёд
  • Inspect actions: видеть каждое действие и его payload
  • Diff state: сравнивать состояние до/после action
  • Import/Export state: сохранять и восстанавливать состояние
// store.ts — Redux DevTools включены автоматически в development
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
// devTools: process.env.NODE_ENV !== 'production' — включено по умолчанию!
});

RTK предоставляет createEntityAdapter для работы со списками сущностей:

import { createEntityAdapter, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
// Адаптер автоматически создаёт CRUD-операции
const usersAdapter = createEntityAdapter<User>({
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
// Асинхронная загрузка
export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
const response = await fetch('/api/users');
return response.json() as Promise<User[]>;
});
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({
loading: false,
error: null as string | null,
}),
reducers: {
// Встроенные методы адаптера
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
usersReceived: usersAdapter.setAll,
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Ошибка загрузки';
});
},
});
// Готовые селекторы из адаптера
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
selectIds: selectUserIds,
selectTotal: selectUsersTotal,
} = usersAdapter.getSelectors((state: RootState) => state.users);
export const { userAdded, userUpdated, userRemoved } = usersSlice.actions;
export default usersSlice.reducer;

RTK Query — встроенный инструмент для работы с сервером, встроенный в RTK:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User'],
}),
getUserById: builder.query<User, string>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
createUser: builder.mutation<User, Partial<User>>({
query: (body) => ({ url: '/users', method: 'POST', body }),
invalidatesTags: ['User'], // Автоматически инвалидирует кэш
}),
updateUser: builder.mutation<User, Pick<User, 'id'> & Partial<User>>({
query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
}),
}),
});
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useCreateUserMutation,
useUpdateUserMutation,
} = usersApi;
// Использование в компоненте
function UserList() {
const { data: users, isLoading, isError } = useGetUsersQuery();
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
if (isLoading) return <div>Загрузка...</div>;
if (isError) return <div>Ошибка!</div>;
return (
<ul>
{users?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}

Сохранение стейта в localStorage:

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsStore {
theme: 'light' | 'dark';
language: 'ru' | 'en';
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (lang: 'ru' | 'en') => void;
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'dark',
language: 'ru',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // Ключ в localStorage
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme, language: state.language }), // Только эти поля
}
)
);
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface TodoStore {
todos: { id: string; text: string; done: boolean }[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeDone: () => void;
}
export const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
// Immer позволяет писать мутабельный код!
state.todos.push({ id: crypto.randomUUID(), text, done: false });
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
}),
removeDone: () =>
set((state) => {
state.todos = state.todos.filter((t) => !t.done);
}),
}))
);
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create<CounterStore>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
reset: () => set({ count: 0 }, false, 'reset'),
}),
{ name: 'CounterStore' } // Имя в Redux DevTools
)
);
// Вместо одного огромного стора — несколько слайсов
import { StateCreator } from 'zustand';
// Слайс для авторизации
interface AuthSlice {
user: User | null;
token: string | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const createAuthSlice: StateCreator<AppStore, [], [], AuthSlice> = (set) => ({
user: null,
token: null,
login: async ({ email, password }) => {
const { user, token } = await api.login({ email, password });
set({ user, token });
},
logout: () => set({ user: null, token: null }),
});
// Слайс для UI
interface UISlice {
sidebarOpen: boolean;
modal: string | null;
toggleSidebar: () => void;
openModal: (name: string) => void;
closeModal: () => void;
}
const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({
sidebarOpen: true,
modal: null,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
openModal: (name) => set({ modal: name }),
closeModal: () => set({ modal: null }),
});
// Объединяем в один стор
type AppStore = AuthSlice & UISlice;
export const useAppStore = create<AppStore>()((...a) => ({
...createAuthSlice(...a),
...createUISlice(...a),
}));

import { atom, useAtom, useAtomValue } from 'jotai';
// Базовые атомы
const firstNameAtom = atom('Иван');
const lastNameAtom = atom('Иванов');
const ageAtom = atom(25);
// Derived atom — только чтение, вычисляется автоматически
const fullNameAtom = atom((get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`);
// Writable derived atom — можно читать и писать
const fullNameWritableAtom = atom(
(get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`,
(get, set, newFullName: string) => {
const [first, ...rest] = newFullName.split(' ');
set(firstNameAtom, first);
set(lastNameAtom, rest.join(' '));
}
);
// Async atom
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
return response.json();
});
import { atomFamily, useAtom } from 'jotai';
// Создаёт уникальный атом для каждого ID
const todoAtomFamily = atomFamily((id: string) =>
atom({ id, text: '', done: false })
);
// Использование
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => setTodo((prev) => ({ ...prev, done: !prev.done }))}
/>
{todo.text}
</label>
);
}
import { useAtomsDevtools } from 'jotai-devtools';
// В корне приложения
function AtomsDebugger() {
useAtomsDevtools('JotaiStore');
return null;
}
export default function App() {
return (
<>
{process.env.NODE_ENV !== 'production' && <AtomsDebugger />}
<YourApp />
</>
);
}

import { atom, selector, useRecoilValue, useRecoilState } from 'recoil';
const todoListState = atom<Todo[]>({
key: 'todoListState',
default: [],
});
const filterState = atom<'all' | 'active' | 'completed'>({
key: 'filterState',
default: 'all',
});
// Selector автоматически пересчитывается при изменении зависимостей
const filteredTodosSelector = selector({
key: 'filteredTodos',
get: ({ get }) => {
const todos = get(todoListState);
const filter = get(filterState);
switch (filter) {
case 'active': return todos.filter((t) => !t.done);
case 'completed': return todos.filter((t) => t.done);
default: return todos;
}
},
});
const statsSelector = selector({
key: 'todoStats',
get: ({ get }) => {
const todos = get(todoListState);
return {
total: todos.length,
active: todos.filter((t) => !t.done).length,
completed: todos.filter((t) => t.done).length,
percentCompleted: todos.length ? Math.round((todos.filter((t) => t.done).length / todos.length) * 100) : 0,
};
},
});
import { atom, AtomEffect } from 'recoil';
// Эффект: синхронизация с localStorage
function localStorageEffect<T>(key: string): AtomEffect<T> {
return ({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(key);
if (savedValue !== null) {
setSelf(JSON.parse(savedValue));
}
onSet((newValue, _, isReset) => {
isReset
? localStorage.removeItem(key)
: localStorage.setItem(key, JSON.stringify(newValue));
});
};
}
const userPrefsAtom = atom({
key: 'userPrefs',
default: { theme: 'dark', lang: 'ru' },
effects: [localStorageEffect('user-prefs')],
});

Redux: Весь store → selector → shouldComponentUpdate → ре-рендер
Zustand: Подписка на slice → точечный ре-рендер
Jotai: Атом изменился → только подписчики этого атома → ре-рендер

Jotai и Recoil выигрывают в сценариях с большим числом независимых небольших данных (таблицы, списки задач, формы).

Zustand выигрывает по простоте и скорости разработки при среднем масштабе.

Redux — оптимален для команд с чёткими процессами, где нужна строгая типизация, аудит и DevTools.

import { useShallow } from 'zustand/react/shallow';
// ❌ Ре-рендер при ЛЮБОМ изменении стора (новая ссылка на объект)
const { count, user } = useStore((state) => ({ count: state.count, user: state.user }));
// ✅ Ре-рендер только если count или user реально изменились
const { count, user } = useStore(
useShallow((state) => ({ count: state.count, user: state.user }))
);
import { createSelector } from '@reduxjs/toolkit';
const selectTodos = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;
// Пересчитывается только при изменении зависимостей
export const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case 'active': return todos.filter((t) => !t.done);
case 'completed': return todos.filter((t) => t.done);
default: return todos;
}
}
);

Когда что выбирать: практическое руководство

Заголовок раздела «Когда что выбирать: практическое руководство»

Маленькое приложение (лендинг, портфолио)

Заголовок раздела «Маленькое приложение (лендинг, портфолио)»
useState + useContext

Не усложняйте. State management — это инструмент для сложности, которой у вас нет.

Zustand + TanStack Query

Zustand для UI-стейта (модалки, фильтры, авторизация), TanStack Query для данных с сервера.

Redux Toolkit + RTK Query

Когда нужны: строгий code review, DevTools, time-travel debugging, большая команда.

Сложные зависимости данных (финансы, аналитика)

Заголовок раздела «Сложные зависимости данных (финансы, аналитика)»
Jotai или Recoil

Когда у вас граф зависимостей, derived data, и важна гранулярность обновлений.

КритерийContext APIReduxZustandJotai
Размер бандла0 (встроен)~10KB~1KB~3KB
Кривая обученияНизкаяВысокаяНизкаяСредняя
BoilerplateМинимумМногоМалоМало
DevToolsНетОтличныеЧерез middlewareОтдельный пакет
SSRСложноХорошоХорошоОтлично
TypeScriptХорошоОтличноОтличноОтлично
ГранулярностьНизкаяСредняяСредняяВысокая

Интерактивное сравнение подходов к управлению стейтом: