← назад к разделу

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

Локальное состояние

Большая часть состояния — локальная: открыт ли выпадающий список, что введено в поле, какая вкладка активна. Такие данные важны только одному компоненту. Инструмент для этого — useState.

function SearchBox() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

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

Когда переходов много и они связаны между собой (например, многошаговая форма), вместо россыпи useState берут useReducer. Он собирает все переходы в одном месте:

type Action = { type: "next" } | { type: "prev" } | { type: "reset" };

function reducer(step: number, action: Action): number {
  switch (action.type) {
    case "next": return step + 1;
    case "prev": return step - 1;
    case "reset": return 0;
  }
}

function Wizard() {
  const [step, dispatch] = useReducer(reducer, 0);
  return (
    <div>
      <p>Шаг {step}</p>
      <button onClick={() => dispatch({ type: "next" })}>Далее</button>
    </div>
  );
}

useReducer удобен, когда следующее значение зависит от предыдущего и переходов больше двух-трёх.

Разделяемое состояние и context

Бывает, что одно и то же состояние нужно нескольким компонентам. Сначала попробуй поднять его к общему родителю — это самый простой способ. Если родитель далеко и пробрасывать через много уровней неудобно, используют context.

const ThemeContext = createContext<"light" | "dark">("light");

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

function Button() {
  const theme = useContext(ThemeContext); // получаем тему без пробрасывания props
  return <button className={theme}>Нажми</button>;
}

Важная особенность: при изменении значения context перерисовываются все компоненты, которые его читают. Поэтому context хорошо работает для редко меняющихся данных — тема оформления, текущий пользователь, язык интерфейса. Для часто меняющихся данных (позиция мыши, текст поля) он вызовет лишние перерисовки.

Серверное состояние — это другое

Вот ключевое различие, которое упрощает многое: данные с сервера — это не состояние приложения, это временная копия серверных данных на клиенте.

Список товаров, профиль пользователя, история заказов — всё это живёт на сервере. На клиенте у тебя лишь копия, которую нужно:

  • загрузить при открытии страницы;
  • обновить, если данные устарели;
  • перезапросить после ошибки;
  • сбросить после изменения (инвалидация).

Если хранить серверные данные в useState, всё это придётся писать вручную. А это уже давно решённая задача: для серверного состояния есть TanStack Query. Он берёт на себя кеширование, повторные запросы, гонки запросов и инвалидацию.

function ProductList() {
  const { data, isPending, isError } = useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((r) => r.json()),
  });

  if (isPending) return <p>Загрузка...</p>;
  if (isError) return <p>Ошибка загрузки</p>;
  return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

Большая часть того, что новички кладут в глобальный стор, — это серверные данные. После того как они уходят в TanStack Query, глобального состояния остаётся совсем мало.

Когда нужен внешний стор

После того как серверное состояние ушло в TanStack Query, а локальное — в useState, для глобального стора (Zustand, Redux) остаётся немного:

  • содержимое корзины до отправки на сервер — это клиентское состояние, которое нужно в разных частях приложения;
  • состояние сложного мастера на несколько экранов — когда данные из шага 1 нужны на шаге 4;
  • глобальные UI-флаги — например, открыта ли боковая панель.

Критерий: стор оправдан, если состояние одновременно (1) клиентское, не кеш сервера, (2) нужно несвязанным компонентам в разных частях дерева и (3) часто меняется, поэтому context не подходит. Если хоть одно условие не выполняется — скорее всего, стор преждевременен.

Zustand — простой выбор для начала: минимум кода, нет boilerplate.

import { create } from "zustand";

interface CartStore {
  items: string[];
  add: (item: string) => void;
}

const useCart = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ name }: { name: string }) {
  const add = useCart((s) => s.add);
  return <button onClick={() => add(name)}>Добавить</button>;
}

Как выбрать инструмент

Простая схема принятия решения:

  1. Состояние нужно только одному компоненту → useState или useReducer.
  2. Нужно нескольким компонентам, но меняется редко → context.
  3. Это данные с сервера → TanStack Query.
  4. Клиентское, нужно многим, часто меняется → стор (Zustand или Redux).

Коротко

  • Состояние — данные, от которых зависит то, что видит пользователь.
  • Держи состояние как можно ближе к месту использования: начинай с useState.
  • useReducer — когда переходов много и они связаны между собой.
  • context — для редко меняющихся данных, которые нужны глубоко в дереве (тема, пользователь, локаль).
  • Серверные данные — это кеш, не состояние приложения. Для них — TanStack Query.
  • Глобальный стор (Zustand, Redux) оправдан только для клиентского состояния, которое нужно несвязанным компонентам и часто меняется.

Что почитать дальше

  • Загрузка данных в React: TanStack Query — как работают запросы, кеш и инвалидация.
  • Хуки React — правила хуков, useEffect и кастомные хуки.
  • React-компоненты и props — как строить компоненты и передавать данные вниз по дереву.