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?