Skip to content

DOMAIN:FRONTEND:STATE_MANAGEMENT

OWNER: floris (Team Alpha), floor (Team Beta)
UPDATED: 2026-03-24
SCOPE: all client projects using Next.js App Router
PRINCIPLE: minimize client state. Server Components + URL state handle 90% of needs.


STATE_CATEGORIES

DECISION_TREE

What kind of state is this?

1. DATA FROM SERVER (products, users, orders)
   → Server Component fetch (default)
   → TanStack Query (if client needs refetch/optimistic updates)
   → NEVER useState + useEffect for initial data load

2. URL STATE (filters, search, pagination, sort, tab selection)
   → nuqs (type-safe URL search params)
   → NEVER useState for shareable/bookmarkable state

3. FORM STATE (field values, validation, pending, errors)
   → useActionState (React 19 — for server action forms)
   → react-hook-form (for complex multi-step forms)
   → NEVER manual useState per field

4. UI STATE (modal open/close, sidebar collapsed, accordion expanded)
   → useState in the component that owns it
   → Lift state up only if sibling needs it
   → Context if deeply nested components need it

5. GLOBAL CLIENT STATE (auth session, theme, feature flags)
   → zustand (simple global store)
   → jotai (atomic, fine-grained reactivity)
   → ONLY when the above categories do not apply

RULE: start with the highest category that fits. Most state is category 1 or 2.
RULE: if you reach for zustand/jotai, ask yourself: can this be URL state or server state instead?


SERVER_STATE (CATEGORY 1)

SERVER_COMPONENT_FETCH

DEFAULT_APPROACH: fetch data directly in async Server Components. Zero client state needed.

// app/products/page.tsx — Server Component
import { db } from "@/lib/db";

export default async function ProductsPage() {
  const products = await db.query.products.findMany({
    where: eq(products.active, true),
    orderBy: [desc(products.createdAt)],
  });

  return <ProductGrid products={products} />;
}

CACHING: controlled via fetch options or unstable_cache. See nextjs-app-router.md.
REVALIDATION: revalidatePath() or revalidateTag() after mutations.
BENEFIT: zero client JS for data fetching. No loading spinners. No stale cache bugs.

TANSTACK_QUERY

USE_WHEN: client component needs to refetch, poll, paginate, or do optimistic updates.
USE_WHEN: real-time data that changes without user action (notifications, live dashboard).
DO_NOT_USE: for initial page data that Server Components can fetch.

"use client";

import { useQuery } from "@tanstack/react-query";

export function LiveNotifications() {
  const { data, isLoading, error } = useQuery({
    queryKey: ["notifications"],
    queryFn: () => fetch("/api/notifications").then((r) => r.json()),
    refetchInterval: 30_000, // Poll every 30s
  });

  if (isLoading) return <NotificationsSkeleton />;
  if (error) return <ErrorMessage error={error} />;

  return <NotificationList notifications={data} />;
}

SETUP: wrap app in QueryClientProvider (Client Component at root).

// components/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute
            gcTime: 5 * 60 * 1000, // 5 minutes
          },
        },
      }),
  );

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

URL_STATE (CATEGORY 2)

NUQS

PURPOSE: type-safe URL search params as React state. Shareable, bookmarkable, back-button friendly.
LIBRARY: nuqs (previously next-usequerystate).

"use client";

import { useQueryState, parseAsInteger, parseAsString, parseAsArrayOf } from "nuqs";

export function ProductFilters() {
  const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
  const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
  const [categories, setCategories] = useQueryState(
    "cat",
    parseAsArrayOf(parseAsString).withDefault([]),
  );
  const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("newest"));

  return (
    <div className="flex gap-4">
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search products..."
      />
      <select value={sort} onChange={(e) => setSort(e.target.value)}>
        <option value="newest">Newest</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
      </select>
      {/* URL updates to: ?q=shoes&page=1&sort=price-asc&cat=sneakers,boots */}
    </div>
  );
}

NUQS_SERVER_SIDE

// app/products/page.tsx — Server Component reads URL params
import { createSearchParamsCache, parseAsInteger, parseAsString } from "nuqs/server";

const searchParamsCache = createSearchParamsCache({
  q: parseAsString.withDefault(""),
  page: parseAsInteger.withDefault(1),
  sort: parseAsString.withDefault("newest"),
});

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[]>>;
}) {
  const { q, page, sort } = searchParamsCache.parse(await searchParams);

  const products = await db.query.products.findMany({
    where: q ? ilike(products.name, `%${q}%`) : undefined,
    orderBy: sort === "price-asc" ? [asc(products.price)] : [desc(products.createdAt)],
    limit: 20,
    offset: (page - 1) * 20,
  });

  return (
    <div>
      <ProductFilters /> {/* Client component that writes URL state */}
      <ProductGrid products={products} /> {/* Server component that reads URL state */}
    </div>
  );
}

BENEFIT: URL is the single source of truth. Server can read it. Client can write it. User can share/bookmark.
BENEFIT: browser back/forward buttons work naturally.

WHEN_URL_STATE

GOOD_FIT: search queries, filters, pagination, sort order, tab selection, date ranges.
BAD_FIT: modal open/close (transient UI), form field values (not shareable), animation state.
LIMIT: URLs have length limits (~2000 chars). Do not serialize large objects.


FORM_STATE (CATEGORY 3)

USE_ACTION_STATE

DEFAULT for GE projects. See react-patterns.md for full examples.

"use client";

import { useActionState } from "react";
import { updateSettings } from "@/lib/actions/update-settings";

export function SettingsForm({ defaults }: { defaults: Settings }) {
  const [state, formAction, isPending] = useActionState(updateSettings, {
    error: null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="companyName" defaultValue={defaults.companyName} />
      <input name="email" type="email" defaultValue={defaults.email} />
      {state.error && <p className="text-destructive">{state.error}</p>}
      {state.success && <p className="text-green-600">Saved!</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save Settings"}
      </button>
    </form>
  );
}

REACT_HOOK_FORM

USE_WHEN: complex multi-step forms, dynamic field arrays, complex validation with live feedback.
LIBRARY: react-hook-form + @hookform/resolvers + zod.

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { projectSchema, type ProjectInput } from "@/lib/schemas/project";
import { createProject } from "@/lib/actions/create-project";

export function ProjectForm() {
  const form = useForm<ProjectInput>({
    resolver: zodResolver(projectSchema),
    defaultValues: { name: "", description: "", budget: 0 },
  });

  async function onSubmit(data: ProjectInput) {
    const result = await createProject(data);
    if (result.error) {
      form.setError("root", { message: result.error });
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register("name")} />
      {form.formState.errors.name && (
        <p className="text-destructive">{form.formState.errors.name.message}</p>
      )}
      <input {...form.register("budget", { valueAsNumber: true })} type="number" />
      <button type="submit" disabled={form.formState.isSubmitting}>
        {form.formState.isSubmitting ? "Creating..." : "Create Project"}
      </button>
    </form>
  );
}

UI_STATE (CATEGORY 4)

LOCAL_USESTATE

DEFAULT for transient UI state (modal open, dropdown expanded, hover state).
RULE: keep state as close to the component that uses it as possible.

"use client";

import { useState } from "react";

export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <aside className={collapsed ? "w-16" : "w-64"}>
      <button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
      {/* sidebar content */}
    </aside>
  );
}

LIFTING_STATE

USE_WHEN: two sibling components need the same state.
PATTERN: move state to their common parent.

"use client";

import { useState } from "react";

export function Dashboard() {
  const [selectedProject, setSelectedProject] = useState<string | null>(null);

  return (
    <div className="grid grid-cols-2 gap-4">
      <ProjectList
        selected={selectedProject}
        onSelect={setSelectedProject}
      />
      <ProjectDetails projectId={selectedProject} />
    </div>
  );
}

CONTEXT

USE_WHEN: state needed by deeply nested components (theme, auth, locale).
AVOID: for data that could be passed as props through 2-3 levels.
NOTE: Context re-renders all consumers when value changes. Split contexts by update frequency.

"use client";

import { createContext, useContext, useState, type ReactNode } from "react";

type SidebarContextType = {
  collapsed: boolean;
  toggle: () => void;
};

const SidebarContext = createContext<SidebarContextType | null>(null);

export function useSidebar() {
  const ctx = useContext(SidebarContext);
  if (!ctx) throw new Error("useSidebar must be used within SidebarProvider");
  return ctx;
}

export function SidebarProvider({ children }: { children: ReactNode }) {
  const [collapsed, setCollapsed] = useState(false);
  return (
    <SidebarContext value={{ collapsed, toggle: () => setCollapsed((c) => !c) }}>
      {children}
    </SidebarContext>
  );
}

GLOBAL_CLIENT_STATE (CATEGORY 5)

ZUSTAND

USE_WHEN: simple global state accessed from many components. Shopping cart, notification queue, wizard steps.
BENEFIT: minimal boilerplate. No providers needed. Works outside React (in utility functions).

// lib/stores/cart-store.ts
import { create } from "zustand";

type CartItem = { id: string; name: string; price: number; quantity: number };

type CartStore = {
  items: CartItem[];
  addItem: (item: Omit<CartItem, "quantity">) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  total: () => number;
};

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i,
          ),
        };
      }
      return { items: [...state.items, { ...item, quantity: 1 }] };
    }),
  removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
  clearCart: () => set({ items: [] }),
  total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
// Usage in any client component
"use client";

import { useCartStore } from "@/lib/stores/cart-store";

export function CartButton() {
  const itemCount = useCartStore((state) => state.items.length);
  return <button>Cart ({itemCount})</button>;
}

IMPORTANT: zustand stores must NOT be created at module level in Server Components. Create them in Client Components or use a Provider pattern.

JOTAI

USE_WHEN: fine-grained atomic state. Each piece of state is independent. Only consumers of that atom re-render.
BENEFIT: more granular re-rendering than zustand.

// lib/atoms/settings.ts
import { atom } from "jotai";

export const themeAtom = atom<"light" | "dark" | "system">("system");
export const sidebarCollapsedAtom = atom(false);
export const notificationCountAtom = atom(0);

// Derived atom (computed from other atoms)
export const hasNotificationsAtom = atom((get) => get(notificationCountAtom) > 0);
// Usage
"use client";

import { useAtom } from "jotai";
import { sidebarCollapsedAtom } from "@/lib/atoms/settings";

export function SidebarToggle() {
  const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
  return <button onClick={() => setCollapsed(!collapsed)}>{collapsed ? ">" : "<"}</button>;
}

ZUSTAND_VS_JOTAI

Criteria Zustand Jotai
Mental model single store with slices independent atoms
Re-renders all selectors re-evaluate on any store change only atom subscribers re-render
Boilerplate minimal very minimal
DevTools zustand/middleware jotai-devtools
Outside React yes (get/set without hooks) no (hook-based)
Best for medium-sized global state many small independent states

GE_DEFAULT: zustand for most projects. Jotai when fine-grained reactivity matters (complex dashboards with 50+ independent state pieces).


ANTI_PATTERNS

ANTI_PATTERN: useState + useEffect for data fetching in App Router projects.
FIX: use Server Components or TanStack Query.

ANTI_PATTERN: useState for filter/search/sort state.
FIX: use nuqs (URL state). Users can share and bookmark.

ANTI_PATTERN: zustand store for server data (products, users).
FIX: fetch in Server Components. Use zustand only for client-only state.

ANTI_PATTERN: prop drilling through 5+ levels.
FIX: use Context for deeply shared state, or restructure with composition (children prop).

ANTI_PATTERN: putting everything in one giant zustand store.
FIX: split into focused stores per domain (cartStore, uiStore, settingsStore).

ANTI_PATTERN: context for frequently changing values (mouse position, scroll offset).
FIX: use refs or local state. Context re-renders all consumers on every change.


SELF_CHECK

BEFORE_ADDING_STATE:
- [ ] can this be fetched in a Server Component instead?
- [ ] should this be URL state (shareable, bookmarkable)?
- [ ] is useActionState sufficient for this form?
- [ ] is local useState enough, or do multiple components need it?
- [ ] if global state — is zustand simpler than Context for this case?
- [ ] am I avoiding prop drilling through more than 3 levels?
- [ ] am I NOT using useState + useEffect for initial data fetch?