Состояние — это данные, которые меняются в процессе работы приложения и от которых зависит то, что видит пользователь. Открыт ли модал, что введено в поле, список товаров с сервера — всё это состояние. Разобраться, где хранить каждый вид, — первый шаг к тому, чтобы код не превратился в клубок.
Локальное состояние
Большая часть состояния — локальная: открыт ли выпадающий список, что введено в поле, какая вкладка активна. Такие данные важны только одному компоненту. Инструмент для этого — 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>;
}
Как выбрать инструмент
Простая схема принятия решения:
- Состояние нужно только одному компоненту →
useStateилиuseReducer. - Нужно нескольким компонентам, но меняется редко →
context. - Это данные с сервера → TanStack Query.
- Клиентское, нужно многим, часто меняется → стор (Zustand или Redux).
Коротко
- Состояние — данные, от которых зависит то, что видит пользователь.
- Держи состояние как можно ближе к месту использования: начинай с
useState. useReducer— когда переходов много и они связаны между собой.context— для редко меняющихся данных, которые нужны глубоко в дереве (тема, пользователь, локаль).- Серверные данные — это кеш, не состояние приложения. Для них — TanStack Query.
- Глобальный стор (Zustand, Redux) оправдан только для клиентского состояния, которое нужно несвязанным компонентам и часто меняется.
Что почитать дальше
- Загрузка данных в React: TanStack Query — как работают запросы, кеш и инвалидация.
- Хуки React — правила хуков, useEffect и кастомные хуки.
- React-компоненты и props — как строить компоненты и передавать данные вниз по дереву.