Управление состоянием в React: серверное, клиентское и состояние в URL

Как разделить состояние React-приложения на виды и выбрать инструмент для каждого: useState и Context, TanStack Query для серверных данных, URL для фильтров, Zustand, Redux Toolkit и Jotai, лишние перерисовки и типичные ошибки.

Спор «Redux или Context» в React-сообществе идёт годами и почти всегда начинается не с того вопроса. Большая часть проблем с состоянием в React-приложениях возникает не из-за выбора библиотеки, а из-за того, что разные виды состояния свалены в одно место: данные с сервера хранятся в глобальном хранилище и вручную синхронизируются, значение поля формы живёт в корне приложения и перерисовывает всё дерево, а фильтры каталога не отражены в адресе страницы и теряются при обновлении. Ниже — как разделить состояние на виды, какой инструмент подходит для каждого, как работают встроенные средства React, когда нужны Zustand, Redux Toolkit, Jotai и библиотеки серверного состояния вроде TanStack Query, как избежать лишних перерисовок и как выбрать решение для конкретного проекта. Главное: состояние бывает разным Серверное состояние — данные, источник истины для которых находится на сервере: списки заказов, профиль пользователя, каталог. Их нужно загружать, кэшировать, обновлять, инвалидировать и синхронизировать. Это самая большая часть «глобального состояния» в типичном приложении — и её не стоит хранить в клиентском хранилище вручную. Состояние адреса — фильтры, сортировка, номер страницы, выбранная вкладка, открытая карточка. Если пользователь должен иметь возможность поделиться ссылкой или вернуться кнопкой «назад», это состояние живёт в URL. Локальное состояние интерфейса — открыт ли выпадающий список, текст в поле ввода, наведение. Живёт в компоненте, которому оно нужно. Состояние форм — значения, ошибки валидации, состояние отправки. Для сложных форм — отдельные библиотеки или встроенные механизмы фреймворка. Глобальное клиентское состояние — то, что действительно разделяется между удалёнными частями интерфейса и не принадлежит серверу: тема оформления, содержимое корзины до оформления, состояние сложного редактора, текущий пользователь после загрузки. После такого разделения обычно выясняется, что по-настоящему глобального клиентского состояния в приложении немного, и спор о библиотеке теряет остроту. Встроенные средства React useState: состояние рядом с использованием Лучшее место для состояния — ближайший общий родитель компонентов, которым оно нужно. Не выше. function SearchBox({ onSearch }) { const [query, setQuery] = useState(''); return ( form onSubmit={(e) = { e.preventDefault(); onSearch(query); }} input value={query} onChange={(e) = setQuery(e.target.value)} / /form ); } Если текст поиска хранится в глобальном хранилище, каждое нажатие клавиши может перерисовывать компоненты по всему приложению. useReducer: сложные переходы Когда состояние меняется по набору правил и несколько значений связаны между собой, редьюсер делает переходы явными и тестируемыми. function checkoutReducer(state, action) { switch (action.type) { case 'next': return { ...state, step: state.step + 1 }; case 'back': return { ...state, step: Math.max(0, state.step - 1) }; case 'submit': return { ...state, status: 'submitting' }; case 'fail': return { ...state, status: 'error', error: action.error }; default: return state; } } const [state, dispatch] = useReducer(checkoutReducer, { step: 0, status: 'idle' }); Производные значения не хранят Если значение вычисляется из существующего состояния или свойств, его не нужно класть в отдельный useState и синхронизировать через эффект — достаточно вычислить при отрисовке. // Плохо: дублирование и лишняя перерисовка const [total, setTotal] = useState(0); useEffect(() = setTotal(items.reduce((s, i) = s + i.price, 0)), [items]); // Хорошо const total = items.reduce((s, i) = s + i.price, 0); Дорогие вычисления можно мемоизировать, но только когда измерения показали, что это нужно. Context: передача, а не хранилище Context решает проблему передачи значений через много уровней компонентов. Он хорошо подходит для редко меняющихся данных: темы, локали, текущего пользователя, зависимостей вроде клиента API. const ThemeContext = createContext('light'); function App() { const [theme, setTheme] = useState('light'); const value = useMemo(() = ({ theme, setTheme }), [theme]); return ( ThemeContext value={value} Layout / /ThemeContext ); } Ограничение Context: при изменении значения перерисовываются все компоненты, которые его читают. Если положить в один контекст часто меняющийся объект с десятком полей, любое изменение одного поля перерисует всех подписчиков. Решения — разделять контексты по частоте изменений или использовать хранилище с подпиской на отдельные части состояния. В примере использована запись ThemeContext value , доступная в React 19; в предыдущих версиях используется ThemeContext.Provider value . Серверное состояние: TanStack Query и аналоги Загрузка данных вручную через useEffect и useState быстро обрастает проблемами: гонки запросов, повторная загрузка одних данных в разных компонентах, отсутствие кэша, ручное обновление после изменений, обработка ошибок и повторных попыток. Библиотеки серверного состояния решают это системно. function OrdersList({ status }) { const { data, isPending, error } = useQuery({ queryKey: ['orders', { status }], queryFn: () = api.getOrders({ status }), staleTime: 30_000, }); if (isPending) return Spinner / ; if (error) return ErrorMessage error={error} / ; return data.map((o) = OrderRow key={o.id} order={o} / ); } function useCancelOrder() { const queryClient = useQueryClient(); return useMutation({ mutationFn: api.cancelOrder, onSuccess: () = queryClient.invalidateQueries({ queryKey: ['orders'] }), }); } Кэширование по ключу, дедупликация одинаковых запросов, фоновое обновление устаревших данных, повторные попытки, инвалидация после изменений и оптимистичные обновления — всё это даётся из коробки. Альтернативы — SWR, RTK Query в экосистеме Redux, клиенты GraphQL со своим кэшем. Если приложение построено на фреймворке с серверными компонентами и загрузкой данных на сервере, значительная часть серверного состояния вообще не попадает в клиентский код. Об этом подходе — в статье о React Server Components . Состояние в адресе страницы Фильтры, сортировка и пагинация в URL дают сразу несколько преимуществ: ссылкой можно поделиться, кнопка «назад» работает ожидаемо, состояние сохраняется при обновлении страницы, а поисковые системы могут индексировать важные варианты списков. function useCatalogFilters() { const [params, setParams] = useSearchParams(); const category = params.get('category') ?? 'all'; const sort = params.get('sort') ?? 'popular'; const update = (patch) = setParams((prev) = { const next = new URLSearchParams(prev); Object.entries(patch).forEach(([k, v]) = (v ? next.set(k, v) : next.delete(k))); return next; }); return { category, sort, update }; } Глобальное клиентское состояние Zustand Минималистичное хранилище без провайдеров и шаблонного кода. Компоненты подписываются на отдельные части состояния через селекторы и перерисовываются только при их изменении. import { create } from 'zustand'; export const useCart = create((set) = ({ items: [], add: (product) = set((s) = ({ items: [...s.items, product] })), remove: (id) = set((s) = ({ items: s.items.filter((i) = i.id !== id) })), clear: () = set({ items: [] }), })); // Компонент перерисуется только при изменении числа товаров const count = useCart((s) = s.items.length); Хороший выбор по умолчанию для небольших и средних приложений, которым нужно немного общего клиентского состояния. Redux Toolkit Современный Redux — это Redux Toolkit: срезы состояния с редьюсерами, неизменяемые обновления в изменяемом стиле, встроенные инструменты для асинхронных операций и RTK Query для серверного состояния. import { createSlice, configureStore } from '@reduxjs/toolkit'; const editorSlice = createSlice({ name: 'editor', initialState: { blocks: [], selectedId: null, history: [] }, reducers: { addBlock(state, action) { state.blocks.push(action.payload); }, select(state, action) { state.selectedId = action.payload; }, }, }); export const { addBlock, select } = editorSlice.actions; export const store = configureStore({ reducer: { editor: editorSlice.reducer } }); Оправдан в крупных приложениях со сложной клиентской логикой, большими командами и потребностью в строгих соглашениях: предсказуемый поток данных, инструменты отладки с историей действий, промежуточные обработчики, единая структура для всех разработчиков. Jotai Атомарный подход: состояние состоит из маленьких независимых атомов, из которых строятся производные. Компоненты подписываются на конкретные атомы. Удобен для интерфейсов с множеством мелких связанных значений — редакторов, конструкторов, сложных форм с зависимостями между полями. Как выбрать Ситуация Решение Данные с сервера TanStack Query, SWR или RTK Query Фильтры, сортировка, пагинация Параметры URL Состояние одного компонента useState или useReducer Редко меняющиеся общие значения Context Немного общего клиентского состояния Zustand Много мелких связанных значений Jotai Крупное приложение со сложной клиентской логикой и большой командой Redux Toolkit Производительность: лишние перерисовки Держите состояние ниже. Состояние, поднятое слишком высоко, перерисовывает больше компонентов, чем нужно. Подписывайтесь на части состояния через селекторы, а не на всё хранилище целиком. Разделяйте контексты по частоте изменений. Не создавайте новые объекты в селекторах без необходимости: селектор, возвращающий новый массив при каждом вызове, вызывает перерисовку каждый раз. Используйте инструменты профилирования React DevTools, прежде чем добавлять мемоизацию. Мемоизация без измерений усложняет код и не всегда ускоряет его. Виртуализируйте длинные списки вместо оптимизации тысяч одновременно отрисованных строк. Компилятор React умеет автоматически мемоизировать компоненты и значения, снижая потребность в ручных useMemo и useCallback в проектах, где он подключён. Общие приёмы ускорения интерфейсов — в статье о performance optimization . Типичные ошибки серверные данные в Redux или Zustand с ручной загрузкой, кэшированием и синхронизацией; дублирование состояния: одно и то же значение в двух местах, синхронизируемое эффектами; один гигантский контекст со всем состоянием приложения; фильтры и пагинация, которые теряются при обновлении страницы; глобальное хранилище для состояния, нужного одному компоненту; выбор Redux «потому что так принято» в небольшом приложении, где он добавляет только шаблонный код; мутация состояния напрямую вне инструментов, которые это поддерживают. Порядок наведения порядка в существующем приложении Инвентаризировать, что лежит в глобальном хранилище, и разметить по видам состояния. Перенести загрузку данных с сервера в библиотеку серверного состояния, начиная с самых используемых запросов. Перенести фильтры и навигационное состояние в URL. Опустить локальное состояние в компоненты, которые им владеют. Оставшееся глобальное клиентское состояние оценить: часто оно помещается в небольшое хранилище или несколько контекстов. Архитектура клиентского состояния тесно связана с архитектурой всего фронтенда. Для больших команд с независимыми частями интерфейса — статья о микрофронтендах ; о типизации хранилищ и данных — материал о продвинутом TypeScript . Архитектуру фронтенда на React и Next.js мы проектируем в рамках разработки веб-проектов . Частые вопросы Нужен ли Redux сегодня? Для многих приложений — нет: серверное состояние лучше решают специализированные библиотеки, а оставшееся клиентское состояние помещается в Context или Zustand. Redux Toolkit оправдан в крупных приложениях со сложной клиентской логикой и большими командами, где ценны строгие соглашения и инструменты отладки. Context заменяет Redux? Не полностью. Context — механизм передачи значений, а не хранилище с оптимизированными подписками. Для редко меняющихся данных его достаточно, для часто меняющегося состояния с множеством подписчиков он приводит к лишним перерисовкам. Где хранить данные текущего пользователя? Загружать через библиотеку серверного состояния или на сервере, а в клиентском коде — читать из её кэша или передавать через Context. Дублировать их в отдельном хранилище обычно не нужно. К