State management в App Router: где Zustand, где useState, где сервер
Когда переезжаешь с pages router на app router, первое, что замечаешь: половина мест, где раньше стоял Redux или Zustand, больше не нужна. Серверные компоненты сами знают данные, и тащить их через клиентский стор просто незачем. Но это не значит, что стейт-менеджеры умерли — они просто переехали в более узкую нишу.
Расскажу, как у меня сейчас распределяется стейт в App Router-проектах: что лежит на сервере, что в URL, что в TanStack Query, что в Zustand, и что в обычном useState. Без подборок «лучших библиотек», только реальная карта.
Четыре уровня стейта
В современном Next.js я мысленно делю состояние на четыре слоя:
- Серверный стейт. Данные из БД, кеша, сессии. Живут на сервере, в клиент уезжают только как HTML.
- URL-стейт. Фильтры, страницы пагинации, выбранная вкладка. То, что должно сохраняться при перезагрузке и шариться ссылкой.
- Кешированный клиентский стейт. Ответы API, которые редактируются с клиента: списки, формы редактирования. TanStack Query или SWR.
- Эфемерный UI-стейт. Открыт ли дропдаун, какая ячейка выбрана, что введено в поле поиска до отправки.
Большая часть проблем со стейтом возникает оттого, что одно и то же состояние пытаются держать на двух уровнях одновременно. Например, фильтр в URL и в Zustand — и они расходятся.
Серверный стейт: пусть остаётся серверным
Главное правило App Router: данные, которые ты получил в серверном компоненте, не нужно дублировать в клиентский стор. Серверный компонент рендерит UI с этими данными, всё.
// app/products/page.tsx
export default async function ProductsPage() {
const products = await getProducts();
return <ProductGrid products={products} />;
}
Если на странице есть кнопка «удалить продукт», она вызывает Server Action или REST-эндпоинт, после чего ты дёргаешь revalidatePath или router.refresh(). Серверный компонент перерендерится, новые данные приедут в HTML. Никакого клиентского стейт-менеджера.
Это работает, пока мутаций мало и пользовательский UX терпит подождать круг до сервера. Когда нужны мгновенные оптимистичные обновления — переходишь на четвёртый слой (TanStack Query) или на useOptimistic для простых случаев.
URL как стейт-менеджер
Я люблю этот слой больше всего. URL — это бесплатный стор, который:
- Шарится ссылкой.
- Восстанавливается при перезагрузке.
- Работает с кнопкой «Назад».
- Виден в инструментах разработчика.
- Не требует библиотек.
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
export function FiltersBar() {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const setCategory = (value: string) => {
const next = new URLSearchParams(params);
if (value) next.set("category", value);
else next.delete("category");
router.replace(`${pathname}?${next.toString()}`);
};
return (
<select
value={params.get("category") ?? ""}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Все</option>
<option value="books">Книги</option>
</select>
);
}
На стороне сервера тот же searchParams приходит как пропс серверного компонента. Получается, что фильтр живёт в URL, серверный компонент читает его и рендерит правильный листинг. Никакого синка, никакого Redux.
Для типизации searchParams у меня обычно небольшой хелпер на zod — чтобы строки приходили в нужном виде:
const filtersSchema = z.object({
category: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
});
const filters = filtersSchema.parse(
Object.fromEntries(new URLSearchParams(searchParamsString)),
);
TanStack Query: для редактируемых данных
Когда у тебя есть список и пользователь его редактирует прямо в UI — добавляет, удаляет, перетаскивает — серверный компонент с revalidatePath начинает раздражать (каждое действие = круг до сервера и пересчёт всей страницы). Тут включаю TanStack Query.
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export function TodoList({ initial }: { initial: Todo[] }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["todos"],
queryFn: () => api.getTodos(),
initialData: initial,
});
const mutation = useMutation({
mutationFn: api.toggleTodo,
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ["todos"] });
const prev = qc.getQueryData<Todo[]>(["todos"]);
qc.setQueryData<Todo[]>(["todos"], (old) =>
old?.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(["todos"], ctx.prev);
},
});
return data?.map((t) => (
<label key={t.id}>
<input
type="checkbox"
checked={t.done}
onChange={() => mutation.mutate(t.id)}
/>
{t.title}
</label>
));
}
Серверный компонент рендерит начальный список, передаёт его в клиентский. initialData в TanStack Query означает «у меня уже есть данные, не нужно делать первый запрос». Дальше клиент сам управляет состоянием через мутации с оптимистичными апдейтами.
Это типичная связка для дашбордов и любых интерактивных списков. SWR справляется с тем же, разница в API и в подходе к мутациям. Я для новых проектов беру TanStack Query, потому что у него мутации устроены чище.
Zustand: для UI-стейта между несвязанными компонентами
Zustand я использую только в одном сценарии: когда несколько компонентов в разных частях страницы должны видеть один UI-стейт, и пробрасывать его через пропсы или context — больно.
Примеры из реальной жизни:
- Открытое модальное окно. Кнопка «Открыть» в шапке, само окно где-то в layout-е, контент в отдельном компоненте.
- Состояние онбординга. Подсказки разбросаны по странице, общий «текущий шаг» удобно держать в одном месте.
- Состояние медиа-плеера. Виджет на сайдбаре, кнопки управления в карточках.
import { create } from "zustand";
type ModalState = {
open: boolean;
modalType: "login" | "signup" | null;
show: (type: "login" | "signup") => void;
close: () => void;
};
export const useModal = create<ModalState>((set) => ({
open: false,
modalType: null,
show: (modalType) => set({ open: true, modalType }),
close: () => set({ open: false, modalType: null }),
}));
Стор маленький, типизированный, без middleware. Если стор начинает разрастаться — это сигнал, что внутри него часть состояния на самом деле относится к другому слою (URL или серверу).
Я не использую Zustand для серверных данных. Никогда. Это дорога к багам синхронизации между «что в сторе» и «что в БД».
useState: эфемерное состояние, не выходящее за компонент
Открыт ли дропдаун, что введено в поле поиска до сабмита, какая вкладка активна. Если состояние не нужно никому, кроме одного компонента и его прямых детей — это useState. Без вариантов.
function Dropdown({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>Меню</button>
{open && <div>{children}</div>}
</div>
);
}
Не тащи это в Zustand «на всякий случай». Чем меньше состояние знает мир, тем легче с ним жить.
Где у меня обычно стояли ошибки
1. Дублирование URL-стейта в Zustand
Самая частая ошибка. Кладёшь фильтр в Zustand, а в URL пишешь его параллельно через эффект. Эффект забывает сработать на первом маунте — фильтр в адресной строке есть, в сторе нет, серверный компонент запросил данные не те. Лечение: оставить только URL.
2. Тащить серверные данные в Zustand
«А давай загрузим список юзеров и положим в стор, чтобы все компоненты могли его читать». Через два месяца у тебя есть список, который не обновляется при изменениях, и тебе нужно вручную писать инвалидацию. То же самое решает TanStack Query из коробки.
3. useState на форму, которая сложнее двух полей
Каждое поле — useState. Каждое нажатие клавиши — ререндер всего дерева формы. На семи полях с валидацией это уже заметно лагает. Решение — react-hook-form, я писала про это отдельно.
Что запомнить
Стейт в App Router-проекте — это четыре слоя с чёткими границами. Сервер хранит свои данные. URL хранит то, что должно переживать перезагрузку. TanStack Query хранит редактируемые серверные данные с клиента. Zustand — для UI-стейта между несвязанными компонентами. useState — для всего остального, что не выходит за пределы компонента.
Не пытайся сложить всё в один большой стор. И не пытайся обойтись только серверными компонентами — на любом интерактивном UI ты быстро упрёшься в их ограничения.