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.
| 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.
Anything encoded in the URL is bookmarkable, shareable and readable by Server Components without hooks:
// 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.
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.
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:
// 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.
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:
// 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.
localStorage during the first render, causing hydration mismatches; read it in an effect.A product list's sort order should survive a page refresh and be shareable. Where should it live?
searchParams.useState, never a module-level global.Next lesson: Internationalization (i18n) ā serve the same application in several languages with locale-aware routing.