lenec ru

← все посты

State management в App Router: где Zustand, где useState, где сервер

11K

Когда переезжаешь с pages router на app router, первое, что замечаешь: половина мест, где раньше стоял Redux или Zustand, больше не нужна. Серверные компоненты сами знают данные, и тащить их через клиентский стор просто незачем. Но это не значит, что стейт-менеджеры умерли — они просто переехали в более узкую нишу.

Расскажу, как у меня сейчас распределяется стейт в App Router-проектах: что лежит на сервере, что в URL, что в TanStack Query, что в Zustand, и что в обычном useState. Без подборок «лучших библиотек», только реальная карта.

Четыре уровня стейта

В современном Next.js я мысленно делю состояние на четыре слоя:

  1. Серверный стейт. Данные из БД, кеша, сессии. Живут на сервере, в клиент уезжают только как HTML.
  2. URL-стейт. Фильтры, страницы пагинации, выбранная вкладка. То, что должно сохраняться при перезагрузке и шариться ссылкой.
  3. Кешированный клиентский стейт. Ответы API, которые редактируются с клиента: списки, формы редактирования. TanStack Query или SWR.
  4. Эфемерный 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 ты быстро упрёшься в их ограничения.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.