State Management in the App Router

Advanced
12 min

State Management in the App Router

In a client-rendered React app, "state" meant data in components or a global store. The App Router splits that into three kinds with different homes, and most pain comes from putting data in the wrong one. After this lesson you will be able to classify state as URL, server or client state, and share client state with Context or Zustand without cross-request leaks.

Three Kinds of State

| Kind | Examples | Where it lives | How it changes | |---|---|---|---| | URL state | Filters, sort order, pagination | searchParams, route params | Link, router.push | | Server state | Products, orders, current user | Database, read by Server Components | Actions + revalidation | | Client state | Open menus, drafts, cart | useState, Context, a store | Client events |

If data must survive a refresh or be shareable, it belongs in the URL or on the server, not in a client store.

URL State: Free Persistence

Anything encoded in the URL is bookmarkable, shareable and readable by Server Components without hooks:

tsx
// app/products/page.tsx export default async function ProductsPage({ searchParams, }: { searchParams: Promise<{ sort?: string; page?: string }>; }) { const { sort = "newest", page = "1" } = await searchParams; const products = await getProducts({ sort, page: Number(page) }); return <ProductGrid products={products} />; }

Changing the query string re-renders on the server, and the back button works.

Server State: Fetch, Mutate, Revalidate

Database data should not be copied into a client store and kept in sync by hand. Read it in Server Components, mutate it with Server Actions, and let revalidatePath or revalidateTag deliver the fresh version. TanStack Query or SWR still fit polling and infinite scroll, with hydration helpers for server-fetched initial data.

Client State With Context

For UI state shared by a subtree, React Context remains the simplest tool. Providers must be Client Components, so give them their own file and let the layout pass server-rendered children through:

tsx
// app/providers.tsx "use client"; import { createContext, useState } from "react"; const SidebarContext = createContext<{ open: boolean; toggle: () => void } | null>(null); export function Providers({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false); return ( <SidebarContext value={{ open, toggle: () => setOpen((o) => !o) }}> {children} </SidebarContext> ); }

Server Components cannot read Context; they get state from the URL, cookies or the database.

Client State With Zustand: One Store per Request

Module-level stores are a trap: the module is evaluated once per server process, so a top-level create() would share one store across every user's request during server rendering. The safe pattern is a factory plus Context, as in the sample at the top of this lesson:

tsx
// stores/cart-provider.tsx "use client"; import { createContext, useContext, useState } from "react"; import { useStore } from "zustand"; import { createCartStore, type CartState } from "./cart-store"; const CartContext = createContext<ReturnType<typeof createCartStore> | null>(null); export function CartProvider({ children, initial }: { children: React.ReactNode; initial?: CartState["items"] }) { const [store] = useState(() => createCartStore(initial)); return <CartContext value={store}>{children}</CartContext>; } export function useCart<T>(selector: (s: CartState) => T) { return useStore(useContext(CartContext)!, selector); }

useState(() => createCartStore()) creates the store once per mounted provider: once per request on the server and once per page load in the browser. The initial prop lets a Server Component seed the store with database data.

Common mistakes

  • Mirroring server data into a client store and then fighting stale copies; revalidate instead.
  • Reading localStorage during the first render, causing hydration mismatches; read it in an effect.
Quick Quiz
Question 1 of 3

A product list's sort order should survive a page refresh and be shareable. Where should it live?

Key Takeaways

  • Classify state as URL, server or client state before choosing a tool.
  • URL state is persistent, shareable and readable via searchParams.
  • Server state stays in the database; mutate with actions and revalidate.
  • Context providers are Client Components that pass server-rendered children through.
  • Create per-request stores with a factory in useState, never a module-level global.

Next lesson: Internationalization (i18n) — serve the same application in several languages with locale-aware routing.

State Management in the App Router - Next.js | CodeYourCraft | CodeYourCraft