Начинайте с useState в компоненте, которому нужно значение, и поднимайте его к общему родителю, когда значение нужно соседям. Context — способ доставить значение глубоко без пропсов, а не стор; Zustand нужен, когда клиентское состояние меняют и читают из далёких частей приложения; данные с сервера держите в кэше TanStack Query, а фильтры и страницы — в URL. Глобальный стор не должен хранить то, что уже лежит на сервере или в адресной строке.
После материала вы сможете
- разложить состояние экрана на виды: локальное UI, общее клиентское, серверное, URL и форма
- выбрать для каждого вида инструмент —
useState, подъём, Context, Zustand, TanStack Query или параметры URL — и обосновать выбор - избежать типичных ловушек: лишних ререндеров Context, селектора Zustand, который каждый раз возвращает новый объект, и серверных данных в глобальном сторе
Что знать заранее
Проверено на
React 19.3Zustand 5.0TanStack Query 5.104TypeScript 6.0Vite 8.3Vitest 5.0 + Testing Library 16.3
Сверено с документацией: 11 октября 2026 г.
Содержание
Какие бывают состояния
Вопрос «какой стейт-менеджер выбрать» обычно задают слишком рано. Сначала стоит понять, что именно хранится: у разных видов состояния разные владельцы, разное время жизни и разные проблемы. Возьмём экран меню кофейни: фильтр по категориям, список напитков с сервера, корзина в шапке и форма заказа.
Локальное UI
- Пример на экране меню
- Открыт ли выпадающий список, значение фильтра в одном блоке
- Кто владелец
- Один компонент
- Куда класть
useStateв нём
Общее клиентское
- Пример на экране меню
- Корзина, которую видят шапка и карточки
- Кто владелец
- Приложение в браузере
- Куда класть
- Подъём состояния → Context → Zustand
Серверное
- Пример на экране меню
- Список напитков и цены
- Кто владелец
- Сервер; в браузере — копия, которая устаревает
- Куда класть
- Кэш TanStack Query
URL
- Пример на экране меню
- Выбранная категория, номер страницы
- Кто владелец
- Адресная строка: ссылкой можно поделиться
- Куда класть
- Параметры поиска (
?category=tea)
Форма
- Пример на экране меню
- Имя и телефон в заказе
- Кто владелец
- Форма до отправки
- Куда класть
- Поля формы или React Hook Form
- 01URLТо, что должно пережить перезагрузку и попасть в ссылку
- 02Кэш серверных данныхКопия данных сервера со сроком свежести
- 03Глобальный клиентский сторНебольшое состояние, которое меняют из разных мест
- 04ContextДоставка значения глубоко по дереву
- 05Локальное состояниеВсё остальное — рядом с компонентом
Форму мы подробно разобрали в уроке «Формы и валидация в React», поэтому дальше — о четырёх остальных видах. Все примеры ниже — одна и та же функция «фильтр + корзина» в разных вариантах; данные общие:
export type Category = "all" | "coffee" | "tea";
export type Product = { id: string; name: string; category: Exclude<Category, "all">; price: number };
export const PRODUCTS: Product[] = [
{ id: "latte", name: "Latte", category: "coffee", price: 1500 },
{ id: "espresso", name: "Espresso", category: "coffee", price: 900 },
{ id: "matcha", name: "Matcha", category: "tea", price: 1700 },
];
export const byCategory = (products: Product[], category: Category) =>
category === "all" ? products : products.filter((p) => p.category === category);Локальное состояние и подъём
Документация React предлагает простое правило: если два компонента должны меняться вместе, уберите состояние из обоих, перенесите в ближайшего общего родителя и передайте вниз через пропсы. Это называется подъёмом состояния (lifting state up). Фильтр нужен только списку — он остаётся в нём. Корзину видит шапка и пополняет список — она поднимается в App.
import { useState } from "react";
import { PRODUCTS, byCategory, type Category } from "../shared/products.ts";
/* the cart is needed by the header and the list → it lives in their closest common parent */
export default function App() {
const [cart, setCart] = useState<string[]>([]);
return (
<>
<header>Cart: {cart.length}</header>
<ProductList onAdd={(id) => setCart((ids) => [...ids, id])} />
</>
);
}
/* the filter is needed only here → local state */
function ProductList({ onAdd }: { onAdd: (id: string) => void }) {
const [category, setCategory] = useState<Category>("all");
return (
<section>
<select aria-label="Category" value={category} onChange={(e) => setCategory(e.target.value as Category)}>
<option value="all">All</option>
<option value="coffee">Coffee</option>
<option value="tea">Tea</option>
</select>
<ul>
{byCategory(PRODUCTS, category).map((p) => (
<li key={p.id}>
{p.name} <button onClick={() => onAdd(p.id)}>Add {p.name}</button>
</li>
))}
</ul>
</section>
);
}Этого хватает дольше, чем кажется. Подъём перестаёт быть удобным, когда значение проходит через несколько промежуточных компонентов, которым оно не нужно (prop drilling — «сверление» пропсами). Но и тут React советует не спешить с Context: сначала попробовать передать пропсы явно — так видно, кто какие данные использует, — или вынести компоненты и передать JSX через children, чтобы сократить число промежуточных уровней.
Context: доставка значения, а не стор
Context позволяет компоненту передать значение всему дереву под ним без пропсов. Сам по себе Context ничего не хранит — хранит useState или useReducer в провайдере, а Context только доставляет. Документация React называет типичные случаи: тема оформления, текущий пользователь, маршрутизация и управление состоянием вместе с редьюсером. В React 19 провайдером служит сам контекст: <CartContext value={…}>.
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
type CartValue = { ids: string[]; add: (id: string) => void };
const CartContext = createContext<CartValue | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [ids, setIds] = useState<string[]>([]);
const add = useCallback((id: string) => setIds((prev) => [...prev, id]), []);
const value = useMemo(() => ({ ids, add }), [ids, add]);
return <CartContext value={value}>{children}</CartContext>;
}
export function useCart() {
const cart = useContext(CartContext);
if (!cart) throw new Error("useCart must be used inside <CartProvider>");
return cart;
}import { CartProvider, useCart } from "./cart-context.tsx";
import { PRODUCTS } from "../shared/products.ts";
export default function App() {
return (
<CartProvider>
<Header />
<main>
{PRODUCTS.map((p) => (
<AddButton key={p.id} id={p.id} name={p.name} />
))}
</main>
</CartProvider>
);
}
function Header() {
const { ids } = useCart();
return <header>Cart: {ids.length}</header>;
}
function AddButton({ id, name }: { id: string; name: string }) {
const { add } = useCart();
return <button onClick={() => add(id)}>Add {name}</button>;
}- Когда меняется значение провайдера (сравнение
Object.is), React перерисовывает все компоненты, которые читают этот контекст.memoэтому не мешает. - Если собирать объект
valueзаново на каждом рендере, потребители будут перерисовываться, даже когда данные не изменились. Поэтомуaddобёрнут вuseCallback, а объект — вuseMemo; документация React описывает это как оптимизацию, заметную не в каждом приложении. - Нельзя подписаться на часть контекста: компонент с одной кнопкой «Добавить» перерисуется при каждом изменении корзины. Для редко меняющихся значений (тема, язык, пользователь) это неважно, для часто меняющихся — повод посмотреть на стор.
Zustand: минимальный глобальный стор
Zustand — небольшая библиотека, в которой стор — это хук. Состояние и действия описываются одной функцией, провайдер не нужен, а компонент подписывается на часть стора через селектор и перерисовывается, только когда результат селектора меняется.
npm install zustand @tanstack/react-queryimport { create } from "zustand";
type CartState = {
qty: Record<string, number>;
add: (id: string) => void;
clear: () => void;
};
export const useCartStore = create<CartState>()((set) => ({
qty: {},
add: (id) => set((state) => ({ qty: { ...state.qty, [id]: (state.qty[id] ?? 0) + 1 } })),
clear: () => set({ qty: {} }),
}));
export const selectCount = (state: CartState) =>
Object.values(state.qty).reduce((sum, n) => sum + n, 0);import { selectCount, useCartStore } from "./cart-store.ts";
import { PRODUCTS } from "../shared/products.ts";
export default function App() {
return (
<>
<Header />
<main>
{PRODUCTS.map((p) => (
<AddButton key={p.id} id={p.id} name={p.name} />
))}
</main>
</>
);
}
function Header() {
const count = useCartStore(selectCount); // re-renders only when the number changes
const clear = useCartStore((state) => state.clear);
return (
<header>
Cart: {count} <button onClick={clear}>Clear</button>
</header>
);
}
function AddButton({ id, name }: { id: string; name: string }) {
const add = useCartStore((state) => state.add);
return <button onClick={() => add(id)}>Add {name}</button>;
}addГлавная ловушка — селектор, который на каждый вызов возвращает новый объект. Мы проверили её на React 19 и Zustand 5: компонент падает с ошибкой «Maximum update depth exceeded», а React перед этим предупреждает, что результат getSnapshot нужно кэшировать. Исправление из документации Zustand — useShallow:
import { useShallow } from "zustand/react/shallow";
// ✗ a new object on every call: React 19 + Zustand 5 fail with
// "Maximum update depth exceeded"
const { count, clear } = useCartStore((s) => ({ count: selectCount(s), clear: s.clear }));
// ✓ useShallow compares the fields of the object, not the reference
const { count, clear } = useCartStore(useShallow((s) => ({ count: selectCount(s), clear: s.clear })));- Стор работает и вне React:
useCartStore.getState().add("latte")— удобно в тестах и обработчиках вне компонентов. - Стор — модульная переменная, общая для всего, что его импортирует. Документация Zustand для Next.js предупреждает: на сервере такой стор общий для всех запросов, поэтому его создают на каждый запрос и передают через Context, а серверные компоненты не должны читать его и писать в него.
- В тестах стор надо сбрасывать:
useCartStore.setState(useCartStore.getInitialState(), true)вbeforeEach.
Серверное состояние: TanStack Query и кэш
Список напитков — не состояние приложения, а копия чужих данных. Документация TanStack Query перечисляет, чем серверное состояние отличается от клиентского: оно хранится удалённо, меняется без вашего ведома и может устареть. Отсюда задачи, которые плохо решаются через useState + useEffect: кэш, дедупликация одинаковых запросов, обновление в фоне, понимание, что данные устарели, и уборка памяти.
import { useQuery } from "@tanstack/react-query";
import type { Category, Product } from "../shared/products.ts";
export async function fetchProducts(category: Category): Promise<Product[]> {
const res = await fetch(`/api/products?category=${category}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useProducts(category: Category) {
return useQuery({
queryKey: ["products", category], // every variable used by queryFn goes into the key
queryFn: () => fetchProducts(category),
staleTime: 60_000, // the menu rarely changes: treat it as fresh for a minute
});
}queryFn, входит в ключimport { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Catalog } from "./Catalog.tsx";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Catalog />
</QueryClientProvider>
</StrictMode>,
);QueryClient на приложениеimport { useState } from "react";
import { useProducts } from "./products-api.ts";
import type { Category } from "../shared/products.ts";
export function Catalog() {
const [category, setCategory] = useState<Category>("all");
const { data, isPending, isError, refetch } = useProducts(category);
return (
<section>
<select aria-label="Category" value={category} onChange={(e) => setCategory(e.target.value as Category)}>
<option value="all">All</option>
<option value="coffee">Coffee</option>
<option value="tea">Tea</option>
</select>
{isPending && <p>Loading…</p>}
{isError && (
<p role="alert">
Could not load the menu. <button onClick={() => refetch()}>Retry</button>
</p>
)}
<ul>
{data?.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</section>
);
}Важно знать умолчания из раздела Important Defaults. Данные из кэша по умолчанию сразу считаются устаревшими (staleTime: 0) и перезапрашиваются в фоне при монтировании нового экземпляра запроса, возврате фокуса в окно и восстановлении сети. Неудачный запрос повторяется 3 раза с растущей задержкой, прежде чем показать ошибку. Неактивные данные удаляются из кэша через 5 минут. Для меню, которое меняется редко, мы поставили staleTime в минуту — и тест ниже проверяет, что при возврате к категории «All» второго запроса нет.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it, vi } from "vitest";
import { Catalog } from "./Catalog.tsx";
import { PRODUCTS, byCategory, type Category } from "../shared/products.ts";
function renderWithClient() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); // a fresh cache per test
return render(
<QueryClientProvider client={client}>
<Catalog />
</QueryClientProvider>,
);
}
function mockApi() {
const fetchMock = vi.fn(async (url: string) => {
const category = new URL(url, "http://test").searchParams.get("category") as Category;
return Response.json(byCategory(PRODUCTS, category));
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
afterEach(() => vi.unstubAllGlobals());
it("loads, filters through the query key and reuses the cache", async () => {
const user = userEvent.setup();
const fetchMock = mockApi();
renderWithClient();
expect(screen.getByText("Loading…")).toBeInTheDocument();
expect(await screen.findByText("Espresso")).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Category"), "tea");
expect(await screen.findByText("Matcha")).toBeInTheDocument();
expect(screen.queryByText("Espresso")).not.toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Category"), "all");
expect(await screen.findByText("Espresso")).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledTimes(2); // "all" came from the cache: staleTime has not passed
});
it("shows an error and retries on demand", async () => {
const user = userEvent.setup();
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 })));
renderWithClient();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load the menu");
mockApi();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("Latte")).toBeInTheDocument();
});QueryClient на каждый тест и retry: false — как советует руководство TanStack Query по тестированиюСостояние в URL: фильтры и пагинация
Если пользователь ждёт, что после перезагрузки или по присланной ссылке откроется тот же фильтр, сортировка или страница, — это состояние адресной строки. Хранить его в useState или сторе значит потерять при обновлении страницы. В браузере с параметрами поиска работает URLSearchParams, а history.replaceState меняет адрес без перезагрузки и без новой записи в истории.
import type { Category } from "../shared/products.ts";
const CATEGORIES: Category[] = ["all", "coffee", "tea"];
export function readCategory(): Category {
const value = new URLSearchParams(window.location.search).get("category");
return CATEGORIES.find((c) => c === value) ?? "all";
}
export function writeCategory(category: Category) {
const params = new URLSearchParams(window.location.search);
if (category === "all") params.delete("category");
else params.set("category", category);
const query = params.toString();
window.history.replaceState(null, "", query ? `?${query}` : window.location.pathname);
}import { useState } from "react";
import { readCategory, writeCategory } from "./category-param.ts";
import type { Category } from "../shared/products.ts";
export function UrlFilter() {
const [category, setCategory] = useState<Category>(readCategory);
function change(next: Category) {
setCategory(next);
writeCategory(next); // a shared link now opens the same filter
}
return (
<select aria-label="Category" value={category} onChange={(e) => change(e.target.value as Category)}>
<option value="all">All</option>
<option value="coffee">Coffee</option>
<option value="tea">Tea</option>
</select>
);
}Значения из URL приходят от пользователя, поэтому их проверяют по списку допустимых. Другие параметры адреса (здесь page) сохраняются. В Next.js и других фреймворках с роутером для этого есть свои хуки — их разбираем в уроке про App Router; принцип тот же.
Таблица выбора и типичные ошибки
Значение нужно одному компоненту
- Инструмент
useState/useReducer- Почему
- Самый короткий путь, ничего не утекает наружу
Значение нужно соседям
- Инструмент
- Подъём в общего родителя
- Почему
- Поток данных явный и виден в пропсах
Редко меняется, нужно глубоко: тема, язык, пользователь
- Инструмент
- Context
- Почему
- Без пропсов через уровни; лишние ререндеры не страшны
Часто меняется и нужно в разных частях: корзина, черновик, плеер
- Инструмент
- Zustand (или подъём + Context в небольшом приложении)
- Почему
- Селекторы: компонент перерисовывается только из-за своей части
Данные с сервера
- Инструмент
- TanStack Query
- Почему
- Кэш, фоновое обновление, повторы и статусы загрузки
Должно пережить перезагрузку и попасть в ссылку
- Инструмент
- Параметры URL
- Почему
- Адрес — единственное место, которое видят и ссылка, и перезагрузка
Поля формы до отправки
- Инструмент
- Поля формы или React Hook Form
- Почему
- Неуправляемые поля не перерисовывают всю форму на каждое нажатие
- Серверные данные не копируются в
useStateили стор «чтобы были под рукой» — иначе появляются две версии правды - Ничего не дублируется: то, что вычисляется из другого состояния (сумма корзины), считается при рендере, а не хранится
- Значение провайдера Context стабильно:
useMemoиuseCallback, если провайдер перерисовывается часто - Селекторы Zustand возвращают примитив, существующую ссылку или используют
useShallow - В ключе запроса есть все переменные, от которых зависит
queryFn - Значения из URL проверяются по списку допустимых
$ npx vitest run --reporter=verbose
✓ src/e-url/UrlFilter.test.tsx > reads the filter from the URL and writes it back
✓ src/e-url/UrlFilter.test.tsx > ignores unknown values
✓ src/cart.test.tsx > cart via lifted state > the header sees what the list adds
✓ src/d-query/Catalog.test.tsx > loads, filters through the query key and reuses the cache
✓ src/d-query/Catalog.test.tsx > shows an error and retries on demand
✓ src/cart.test.tsx > cart via context > the header sees what the list adds
✓ src/cart.test.tsx > cart via zustand > the header sees what the list adds
✓ src/cart.test.tsx > local filter state does not leak into the cart
✓ src/cart.test.tsx > useCart outside the provider fails loudly
✓ src/cart.test.tsx > zustand store works outside React too
Test Files 3 passed (3)
Tests 10 passed (10)Как проверено7 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/shared/products.ts, src/a-lifted/App.tsx
tsc -b + vite build + vitest: корзина в шапке после двух добавлений; локальный фильтр не влияет на корзину. React 19.3.0, TypeScript 6.0.3, Vite 8.3.4, Vitest 5.0.3, @testing-library/react 16.3.3, jsdom 30.1.2
- запущено, работает
src/b-context/cart-context.tsx, src/b-context/App.tsx
тот же сценарий корзины (describe.each) + ошибка useCart вне провайдера; oxlint: предупреждение only-export-components (Fast Refresh) — оставлено осознанно, описано в статье
- запущено, работает
src/c-zustand/cart-store.ts, src/c-zustand/App.tsx
тот же сценарий корзины + getState() вне React; сброс через getInitialState в beforeEach. Zustand 5.0.15
- запущено, работает
Фрагмент селекторов (новый объект vs useShallow)
vitest: селектор с новым объектом бросает «Maximum update depth exceeded», console.error «The result of getSnapshot should be cached…»; с useShallow рендерится
- запущено, работает
src/d-query/products-api.ts, Catalog.tsx, main.tsx, Catalog.test.tsx
vitest с подменой fetch (vi.stubGlobal): загрузка, смена ключа по фильтру, возврат к 'all' из кэша (2 вызова fetch), ошибка 500 → alert → Retry. TanStack Query 5.104.2. Реальный API не вызывался
- запущено, работает
src/e-url/category-param.ts, UrlFilter.tsx
vitest в jsdom: чтение ?category=tea, запись с сохранением page=2, удаление параметра для 'all', игнор неизвестного значения
- запущено, работает
Упражнения (remove в сторе; фильтр Catalog в URL)
решения собраны в копиях файлов и проверены тестами (tsc -b, vitest: 14 тестов всего)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.