Как делать11 октября 2026 г.6 мин чтения
ReactШаг 6 из 8

Управление состоянием: локальное, Context, Zustand и серверное

Коротко

Начинайте с 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 г.

Содержание
  1. 01Какие бывают состояния
  2. 02Локальное состояние и подъём
  3. 03Context: доставка значения, а не стор
  4. 04Zustand: минимальный глобальный стор
  5. 05Серверное состояние: TanStack Query и кэш
  6. 06Состояние в URL: фильтры и пагинация
  7. 07Таблица выбора и типичные ошибки

Какие бывают состояния

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

Локальное UI

Пример на экране меню
Открыт ли выпадающий список, значение фильтра в одном блоке
Кто владелец
Один компонент
Куда класть
useState в нём

Общее клиентское

Пример на экране меню
Корзина, которую видят шапка и карточки
Кто владелец
Приложение в браузере
Куда класть
Подъём состояния → Context → Zustand

Серверное

Пример на экране меню
Список напитков и цены
Кто владелец
Сервер; в браузере — копия, которая устаревает
Куда класть
Кэш TanStack Query

URL

Пример на экране меню
Выбранная категория, номер страницы
Кто владелец
Адресная строка: ссылкой можно поделиться
Куда класть
Параметры поиска (?category=tea)

Форма

Пример на экране меню
Имя и телефон в заказе
Кто владелец
Форма до отправки
Куда класть
Поля формы или React Hook Form
  1. 01URLТо, что должно пережить перезагрузку и попасть в ссылку
  2. 02Кэш серверных данныхКопия данных сервера со сроком свежести
  3. 03Глобальный клиентский сторНебольшое состояние, которое меняют из разных мест
  4. 04ContextДоставка значения глубоко по дереву
  5. 05Локальное состояниеВсё остальное — рядом с компонентом
Слои состояния: чем ниже, тем ближе к компоненту и тем проще.

Форму мы подробно разобрали в уроке «Формы и валидация в React», поэтому дальше — о четырёх остальных видах. Все примеры ниже — одна и та же функция «фильтр + корзина» в разных вариантах; данные общие:

src/shared/products.ts
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.

src/a-lifted/App.tsx
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>
  );
}
Вариант 1: фильтр локальный, корзина поднята в общего родителя

Этого хватает дольше, чем кажется. Подъём перестаёт быть удобным, когда значение проходит через несколько промежуточных компонентов, которым оно не нужно (prop drilling — «сверление» пропсами). Но и тут React советует не спешить с Context: сначала попробовать передать пропсы явно — так видно, кто какие данные использует, — или вынести компоненты и передать JSX через children, чтобы сократить число промежуточных уровней.

Context: доставка значения, а не стор

Context позволяет компоненту передать значение всему дереву под ним без пропсов. Сам по себе Context ничего не хранит — хранит useState или useReducer в провайдере, а Context только доставляет. Документация React называет типичные случаи: тема оформления, текущий пользователь, маршрутизация и управление состоянием вместе с редьюсером. В React 19 провайдером служит сам контекст: <CartContext value={…}>.

src/b-context/cart-context.tsx
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;
}
Провайдер корзины и хук с понятной ошибкой вне провайдера
src/b-context/App.tsx
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>;
}
Вариант 2: шапка и кнопки читают корзину из контекста, без пропсов
  • Когда меняется значение провайдера (сравнение Object.is), React перерисовывает все компоненты, которые читают этот контекст. memo этому не мешает.
  • Если собирать объект value заново на каждом рендере, потребители будут перерисовываться, даже когда данные не изменились. Поэтому add обёрнут в useCallback, а объект — в useMemo; документация React описывает это как оптимизацию, заметную не в каждом приложении.
  • Нельзя подписаться на часть контекста: компонент с одной кнопкой «Добавить» перерисуется при каждом изменении корзины. Для редко меняющихся значений (тема, язык, пользователь) это неважно, для часто меняющихся — повод посмотреть на стор.

Zustand: минимальный глобальный стор

Zustand — небольшая библиотека, в которой стор — это хук. Состояние и действия описываются одной функцией, провайдер не нужен, а компонент подписывается на часть стора через селектор и перерисовывается, только когда результат селектора меняется.

bash
npm install zustand @tanstack/react-query
src/c-zustand/cart-store.ts
import { 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);
Стор корзины: количество по товарам и два действия
src/c-zustand/App.tsx
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>;
}
Вариант 3: шапка подписана на число, кнопки — только на действие add

Главная ловушка — селектор, который на каждый вызов возвращает новый объект. Мы проверили её на React 19 и Zustand 5: компонент падает с ошибкой «Maximum update depth exceeded», а React перед этим предупреждает, что результат getSnapshot нужно кэшировать. Исправление из документации Zustand — useShallow:

TSX
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: кэш, дедупликация одинаковых запросов, обновление в фоне, понимание, что данные устарели, и уборка памяти.

src/d-query/products-api.ts
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, входит в ключ
src/d-query/main.tsx
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 на приложение
src/d-query/Catalog.tsx
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» второго запроса нет.

src/d-query/Catalog.test.tsx
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 меняет адрес без перезагрузки и без новой записи в истории.

src/e-url/category-param.ts
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);
}
Чтение и запись параметра; неизвестное значение превращается в «all»
src/e-url/UrlFilter.tsx
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 и записывает новое в обработчике, без эффекта

Значения из 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 проверяются по списку допустимых
terminal
$ 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)
Реальный вывод Vitest (время опущено): три варианта корзины проходят один и тот же тест
Как проверено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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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