Управление состоянием лежит в основе каждого нетривиального приложения React. Сделайте это правильно, и ваша кодовая база будет изящно масштабироваться; ошибетесь, и в конечном итоге вы столкнетесь с кошмарами, связанными с бурением реквизита, устаревшим пользовательским интерфейсом и компонентами, которые перерисовываются в небытие. Хорошей новостью является то, что экосистема React значительно повзрослела. Теперь у вас есть богатое меню специально созданных инструментов — Redux Toolkit, Zustand, Jotai, TanStack Query и встроенный Context API — каждый из которых оптимизирован для определенного класса проблем.
В этой статье подробно рассматривается каждое решение, объясняются компромиссы на конкретных примерах и предлагается схема принятия решений, которую вы можете немедленно применить к своим собственным проектам.
Понимание различных видов государства
Прежде чем обращаться к какой-либо библиотеке, стоит точно определить, каким состоянием вы на самом деле управляете. Объединение различных категорий состояний является основной причиной большинства переусложненных приложений React.
- Состояние локального пользовательского интерфейса — открыт ли выпадающий список, какая вкладка активна, текущее значение управляемого входа. Это состояние принадлежит одному компоненту или небольшому поддереву.
- Общее состояние клиента — данные, которые необходимо прочитать или записать нескольким, потенциально удаленным компонентам: текущий аутентифицированный пользователь, настройки темы, корзина покупок.
- Состояние сервера — данные, поступающие на сервер, являются асинхронными по своей природе и требуют аннулирования кэша, фоновой повторной выборки и управления жизненным циклом загрузки/ошибок.
- Состояние URL — фильтры, нумерация страниц и условия поиска, которые должны сохраняться после обновления страницы и быть доступны для совместного использования по ссылке.
- Состояние формы — контролируемые входные данные, ошибки проверки и статус отправки, часто лучше всего обрабатываемые специальной библиотекой, такой как React Hook Form.
Самое эффективное, что вы можете сделать, — это не поддаваться желанию объединить все категории состояний в одно глобальное хранилище. Каждая категория имеет разные требования к согласованности, разное время жизни и разную частоту обновлений.
Контекст React: встроенная опция
Context API поставляется с React и не требует дополнительных зависимостей. Это хорошо работает для состояния, которое меняется нечасто и используется многими компонентами — классическими примерами являются тема, локаль и объект аутентифицированного пользователя.
// AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface AuthState {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (credentials: Credentials) => {
const user = await authService.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}
Ловушка эффективности контекста
Контекст имеет хорошо известную характеристику производительности, которая сбивает с толку многих разработчиков: каждый потребитель выполняет повторный рендеринг всякий раз, когда изменяется ссылка на значение контекста. Если вы храните большой, часто мутирующий объект в одном контексте, вы вызовете ненужные повторные рендеринги по всему дереву.
Стратегии смягчения последствий:
- Разделение контекстов по частоте обновления. Сохраняйте объект пользователя в одном контексте, а настройки пользовательского интерфейса — в другом.
- Запомните объект значения с помощью
useMemoпоэтому ссылка меняется только тогда, когда данные действительно изменяются. - Использование
React.memoна потребительских компонентах, чтобы пропустить повторный рендеринг, если реквизиты, которые им интересны, не изменились.
Честный вывод заключается в том, что Context отлично подходит для низкочастотного глобального состояния, но не является универсальным решением для управления состоянием. Как только вы обнаружите, что пишете сложную систему запоминания, чтобы обойти поведение повторного рендеринга Context, пришло время обратиться к специальной библиотеке.
Redux Toolkit: выбор зрелого предприятия
Redux имеет репутацию шаблонного продукта — репутацию, которую он заслужил еще до появления Toolkit. Redux Toolkit (RTK) устраняет церемонию, сохраняя при этом то, что делает Redux мощным: единое, предсказуемое, проверяемое дерево состояний со строгим однонаправленным потоком данных.
корабли РТК createSlice, который генерирует создатели и редюсеры действий из одного определения и использует Immer под капотом, чтобы вы могли писать мутации, которые выглядят обязательными, но на самом деле применяются неизменяемо.
// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
}
interface CartState {
items: CartItem[];
coupon: string | null;
}
const initialState: CartState = { items: [], coupon: null };
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<CartItem>) {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter(i => i.id !== action.payload);
},
applyCoupon(state, action: PayloadAction<string>) {
state.coupon = action.payload;
},
},
});
export const { addItem, removeItem, applyCoupon } = cartSlice.actions;
export default cartSlice.reducer;
RTK-запрос: состояние сервера внутри Redux
RTK предлагает сопутствующий продукт под названием RTK Query, который обрабатывает состояние сервера непосредственно в хранилище Redux. Он автоматически генерирует перехватчики React на основе определений конечных точек и управляет кэшированием, аннулированием и оптимистическими обновлениями.
// services/productsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product'],
endpoints: (builder) => ({
getProducts: builder.query<Product[], void>({
query: () => '/products',
providesTags: ['Product'],
}),
updateProduct: builder.mutation<Product, Partial<Product>>({
query: ({ id, ...patch }) => ({
url: `/products/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: ['Product'],
}),
}),
});
export const { useGetProductsQuery, useUpdateProductMutation } = productsApi;
Redux Toolkit — правильный выбор, когда вам нужна отладка с перемещением во времени с помощью Redux DevTools, когда нескольким командам необходимо внести свой вклад в модель общего состояния с обязательными соглашениями или когда у вас сложная межсрезовая бизнес-логика, которая получает преимущества от промежуточного программного обеспечения, такого как redux-saga или redux-observable.
Зустанд: легкое глобальное государство без церемоний
Зустанд придерживается радикально иного подхода. Нет ни провайдера, ни редуктора, ни типа действия. Вы определяете хранилище как простой объект JavaScript с состоянием и методами, а затем используете его с помощью одного перехватчика. Весь API умещается на одном экране.
// stores/useCartStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
totalPrice: () => number;
clear: () => void;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + item.quantity }
: i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
totalPrice: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
clear: () => set({ items: [] }),
}),
{ name: 'cart-storage' }
)
)
);
Детальные подписки и производительность
Zustand решает проблему повторного рендеринга Context с помощью подписок на основе селектора. Компонент выполняет повторную визуализацию только тогда, когда выбранный им фрагмент состояния изменился.
// Only re-renders when items.length changes, not on price changes
const itemCount = useCartStore((state) => state.items.length);
// Only re-renders when the total changes
const total = useCartStore((state) => state.totalPrice());
Экосистема промежуточного программного обеспечения Zustand обеспечивает постоянство localStorage, интеграция DevTools, мутации в стиле Immer и синхронизация URL-адресов. Это лучший выбор для команд, которым нужны возможности уровня Redux без затрат на установку уровня Redux.
Джотай: атомное состояние, вдохновленное отдачей
Модели Джотаи представляют собой граф атомов — крошечных, составных единиц состояния, которые можно получить друг из друга. Эта модель особенно эффективна для динамического состояния: представьте себе список узлов редактора, где каждый узел имеет свое собственное независимое состояние выбора/фокуса, или сложную форму, в которой видимость поля зависит от значений других полей.
// atoms/filterAtoms.ts
import { atom, selector } from 'jotai';
export const searchTermAtom = atom('');
export const categoryAtom = atom<string | null>(null);
export const productsAtom = atom<Product[]>([]);
// Derived atom — recomputes only when its dependencies change
export const filteredProductsAtom = atom((get) => {
const products = get(productsAtom);
const term = get(searchTermAtom).toLowerCase();
const category = get(categoryAtom);
return products.filter((p) => {
const matchesTerm = p.name.toLowerCase().includes(term);
const matchesCategory = category === null || p.category === category;
return matchesTerm && matchesCategory;
});
});
// ProductList.tsx
import { useAtom, useAtomValue } from 'jotai';
function ProductList() {
const [searchTerm, setSearchTerm] = useAtom(searchTermAtom);
const filtered = useAtomValue(filteredProductsAtom);
return (
<>
<input value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
{filtered.map((p) => <ProductCard key={p.id} product={p} />)}
</>
);
}
Детализированная модель реактивности Jotai означает, что при изменении поискового запроса только те компоненты, которые действительно подписаны на filteredProductsAtom перерисовать. Компоненты подписаны только на categoryAtom нетронуты. Это делает Jotai отличным решением для приложений с плотными взаимозависимыми графами состояний.
TanStack Query: правильный дом для состояния сервера
TanStack Query (ранее React Query) — это не библиотека общего управления состоянием, а библиотека состояний сервера, и она справляется с этой задачей лучше, чем что-либо еще в экосистеме. Извлечение, кэширование, синхронизация и обновление удаленных данных в React без TanStack Query обычно означает ручное управление флагами загрузки, объектами ошибок и аннулированием кэша либо в локальном состоянии, либо в глобальном хранилище. TanStack Query автоматизирует все это.
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useProducts(filters: ProductFilters) {
return useQuery({
queryKey: ['products', filters],
queryFn: () => api.getProducts(filters),
staleTime: 5 * 60 * 1000, // treat data as fresh for 5 minutes
gcTime: 10 * 60 * 1000, // keep unused data in cache for 10 minutes
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: ProductUpdate) => api.updateProduct(update),
// Optimistic update
onMutate: async (update) => {
await queryClient.cancelQueries({ queryKey: ['products'] });
const previous = queryClient.getQueryData(['products']);
queryClient.setQueryData(['products'], (old: Product[]) =>
old.map((p) => (p.id === update.id ? { ...p, ...update } : p))
);
return { previous };
},
onError: (_err, _update, context) => {
queryClient.setQueryData(['products'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Массив ключей запроса — это примитив кэширования TanStack Query. Любой запрос, использующий один и тот же ключ, использует одну и ту же запись в кэше. Когда вы аннулируете ключ, каждый компонент, подписанный на этот ключ, автоматически выполняет повторную выборку в фоновом режиме и обновляется при поступлении новых данных.
Предварительная выборка и гидратация в Next.js
В приложении Next.js вы можете предварительно получать запросы на сервере и превращать их в полезную нагрузку HTML, полностью исключая загрузку счетчиков для рендеринга начальной страницы.
// app/products/page.tsx (Next.js App Router)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function ProductsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['products', {}],
queryFn: () => api.getProducts({}),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}
Объединение библиотек: практичная архитектура
Реальные приложения почти всегда выигрывают от объединения нескольких инструментов, каждый из которых отвечает за свою категорию состояния. Хорошо структурированное приложение React для среднего и крупного бизнеса может выглядеть следующим образом:
- Танстек-запрос владеет всем состоянием сервера. Он извлекает, кэширует и синхронизирует удаленные данные. Ничто другое не влияет на ответы API.
- Зустанд владеет глобальным состоянием клиента — корзиной покупок, аутентифицированным сеансом пользователя, настройками пользовательского интерфейса и любой межфункциональной координацией, которая слишком сложна для Context.
- Контекст React владеет конфигурацией с низким уровнем оттока — текущая тема, локаль, флаги функций.
- useState/useReducer собственное состояние локального компонента — модальное открытие/закрытие, значения полей формы (или форма React Hook Form для сложных форм), выбор вкладок.
- Параметры поиска URL собственное состояние навигации — активные фильтры, порядок сортировки, номер текущей страницы.
Такое разделение интересов носит не только эстетический характер. Это означает, что ваш кеш состояния сервера никогда не загрязняется состоянием пользовательского интерфейса, ваше глобальное хранилище клиентов никогда не раздувается данными ответов, которые TanStack Query будет кэшировать более эффективно, а ваш контекст никогда не запускает повторную отрисовку всего приложения, потому что кто-то сохранил в нем быстро меняющееся значение.
Шаблоны производительности, которые стоит знать
Мемоизированные селекторы с функцией Reselect
При получении вычисленных значений из хранилища Redux используйте Reselect, чтобы избежать повторных вычислений при каждом рендеринге. РТК реэкспорт createSelector из Повторного выбора.
import { createSelector } from '@reduxjs/toolkit';
const selectItems = (state: RootState) => state.cart.items;
export const selectCartSummary = createSelector(selectItems, (items) => ({
count: items.reduce((n, i) => n + i.quantity, 0),
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
Разделение рендеров с помощью useTransition
React 18-х годов useTransition позволяет пометить обновление состояния как несрочное, чтобы браузер мог прервать его для выполнения более приоритетной работы, например ввода данных пользователем. Это особенно полезно, когда изменение состояния вызывает дорогостоящий повторный рендеринг.
const [isPending, startTransition] = useTransition();
const handleFilterChange = (value: string) => {
startTransition(() => {
setFilterValue(value); // expensive downstream re-render is deferrable
});
};
Как избежать проблем с идентификацией объектов
Распространенным источником фантомных повторных рендерингов является создание новых литералов объекта или массива внутри рендеринга. useMemo and useCallback — это ваши инструменты, но применяйте их только тогда, когда у вас есть доказательства проблем с производительностью — преждевременное запоминание увеличивает когнитивные издержки без гарантированной пользы.
Структура принятия решений
Используйте следующее дерево решений при выборе подхода к управлению состоянием для новой функции:
- Является ли состояние чисто локальным для одного компонента или небольшого поддерева? Использование
useStateилиuseReducer. - Представляет ли состояние данные, полученные с сервера? Использование Танстек-запрос.
- Является ли это глобальное состояние клиента необходимым для многих компонентов в дереве? Достичь Зустанд если вы еще не используете кодовую базу Redux.
- Состояние меняется редко и служит ли целям конфигурации (тема, локаль)? Использование Контекст React.
- Является ли государство тесно взаимосвязанным со многими производными ценностями? Рассмотрим Джотай.
- Вам нужна отладка с путешествиями во времени, строгие архитектурные соглашения или сложные асинхронные саги? Использование Инструментарий Redux.
Распространенные антипаттерны, которых следует избегать
- Хранение ответов сервера в Redux или Zustand. Позвольте TanStack Query владеть этими данными. Дублирование его в глобальном хранилище приводит к ошибкам синхронизации.
- Размещение всего в одном огромном магазине. Несвязанное состояние, принадлежащее одному и тому же срезу, приводит к ненужной связи и усложняет тестирование.
- Использование контекста для высокочастотных обновлений. Каждое изменение значения контекста повторно отображает всех потребителей. Для значений, которые меняются при каждом нажатии клавиши или кадре анимации, Context — неправильный инструмент.
- Получение состояния внутри компонентов. Вычисляемые значения, которые зависят от состояния, должны быть запоминаемыми селекторами или производными атомами, а не встроенными вычислениями, которые выполняются при каждом рендеринге.
- Игнорирование состояний загрузки и ошибок. Состояние сервера по своей сути асинхронно. Поверхности запросов TanStack
isLoading,isErrorиerrorдля каждого запроса; используйте их.
Conclusion
Ландшафт управления состоянием в React никогда не был более здоровым. Вам больше не нужно выбирать между тяжелым фреймворком и построением всего с нуля. Redux Toolkit обеспечивает предсказуемость корпоративного уровня без шаблонов своего предшественника. Zustand обеспечивает ту же мощность, но с небольшой настройкой. Jotai решает сложные проблемы с помощью детальных динамических графов состояний. А TanStack Query окончательно определяет состояние сервера — если вы все еще управляете ответами API в useEffect and useState, вы обязаны мигрировать ради себя.
Самый важный вывод носит категориальный характер: сначала определите, с каким состоянием вы имеете дело, а затем выберите инструмент, оптимизированный для этой категории. Не поддавайтесь инстинкту свести все в одну систему. Многоуровневый подход — TanStack Query для состояния сервера, Zustand для глобального состояния клиента, Context для конфигурации и локальное состояние для всего остального — создает базы кода, которые легче анализировать, легче тестировать и гораздо легче поддерживать по мере роста команд и требований.