Client state: when you need it and what to use
"Which state library should I use?" is the wrong first question. The right one
is whether you need client state at all — in a Next.js application, a large
share of what people put in useState does not belong there.
Four kinds of state
Separating these resolves most of the confusion.
Server state. Data that lives in your database — products, orders, the current user. It is not really yours; you hold a copy that can go stale.
URL state. Filters, search terms, the current page, a selected tab. Anything that should survive a refresh or be shareable by copying the address.
Form state. What is currently typed into a form, before submission.
UI state. A dropdown being open, a modal being shown, which tab is active when nobody needs to link to it.
Only the last two are genuinely client state, and UI state is almost always local to one component.
Server state belongs on the server
"use client";
export function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => { fetch("/api/products")... }, []);
}
The effects lesson covered this. Fetch in a server component instead, and there is no state to manage, no loading flag, and nothing to go stale.
When you genuinely need client-side server state — polling an order status,
infinite scroll — use a library built for it rather than useState and
useEffect:
npm install @tanstack/react-query --workspace=apps/web
"use client";
import { useQuery } from "@tanstack/react-query";
export function OrderStatus({ orderId }: { orderId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ["order", orderId],
queryFn: () => fetch(`/api/orders/${orderId}`).then((r) => r.json()),
refetchInterval: 10_000,
});
if (isLoading) return <p>Checking…</p>;
if (error) return <p>Could not load the order status.</p>;
return <p>Status: {data.status}</p>;
}
That handles caching, deduplication, refetching, retries and stale data — all things you would otherwise write by hand and get subtly wrong.
Do not reach for React Query for everything. In an App Router project most data should come from server components. It earns its place for data that changes while the user watches.
URL state belongs in the URL
const [category, setCategory] = useState("all"); // lost on refresh
Filters in useState cannot be shared, break the Back button, and reset on
reload. Put them in the URL:
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function CategoryFilter({ categories }: { categories: string[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const current = searchParams.get("category") ?? "all";
function select(category: string) {
const params = new URLSearchParams(searchParams);
if (category === "all") {
params.delete("category");
} else {
params.set("category", category);
}
params.delete("page"); // a new filter starts at page 1
router.push(`${pathname}?${params.toString()}`);
}
return (
<div className="flex flex-wrap gap-2">
{["all", ...categories].map((category) => (
<button
key={category}
onClick={() => select(category)}
aria-pressed={current === category}
className={
current === category
? "rounded-full bg-emerald-600 px-3 py-1 text-sm text-white"
: "rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700"
}
>
{category}
</button>
))}
</div>
);
}
The server component reads searchParams and returns the filtered products, so
changing a filter re-runs the query on the server. Nothing is duplicated.
Three details: delete a parameter rather than setting it to "all", so clean
URLs stay clean; reset the page when a filter changes, or the user lands on
page 4 of three results; and aria-pressed tells a screen reader which
filter is active, since colour alone does not.
For a search box, debounce before pushing — the custom hook from the composition lesson — or every keystroke becomes a history entry.
The test: could a user copy this URL to somebody and have them see the same thing? If it should work, the state belongs in the URL.
UI state stays local
"use client";
export function FilterDrawer({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="secondary" onClick={() => setOpen(true)}>Filters</Button>
{open && <div className="fixed inset-0 z-50 bg-white p-4">{children}</div>}
</>
);
}
Nobody outside needs to know whether the drawer is open. Keep it here.
Put UI state in the component that owns it, and lift it only when a sibling genuinely needs it.
When something is genuinely shared
A cart is the real case: the header shows a count, the cart page shows items, and every product card can add to it.
Context is the built-in answer:
"use client";
import { createContext, useContext, useState } from "react";
type CartContextValue = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
totalPaise: number;
};
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const value: CartContextValue = {
items,
addItem: (item) =>
setItems((current) => {
const existing = current.find((i) => i.productId === item.productId);
return existing
? current.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
)
: [...current, item];
}),
removeItem: (id) =>
setItems((current) => current.filter((i) => i.productId !== id)),
totalPaise: items.reduce((sum, i) => sum + i.pricePaise * i.quantity, 0),
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used inside a CartProvider");
}
return context;
}
Three things worth copying.
totalPaise is calculated, not stored — the derived-state rule.
useCart throws a clear error when used outside the provider. Without it
you get Cannot read property 'items' of null somewhere unhelpful.
The provider wraps as little as possible. Putting it in the root layout makes the entire application a client component. Wrap the part that needs it:
// src/app/(shop)/layout.tsx
export default function ShopLayout({ children }: { children: React.ReactNode }) {
return <CartProvider>{children}</CartProvider>;
}
What Context costs
Every consumer re-renders when any part of the value changes. A cart context holding both items and an open/closed drawer flag re-renders every product card when the drawer opens.
Split unrelated concerns into separate contexts, or reach for a library.
When to use a state library
Zustand is the smallest reasonable option:
npm install zustand --workspace=apps/web
import { create } from "zustand";
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
totalPaise: () => get().items.reduce((sum, i) => sum + i.pricePaise * i.quantity, 0),
}));
const items = useCartStore((s) => s.items); // re-renders only when items change
No provider, and components subscribe to the slice they use.
Reach for it when Context re-renders become a measured problem, or the shared state gets complex. Not before — Context is built in and adequate for a cart.
Redux Toolkit is still common in large applications and in existing codebases you will join. It is more ceremony than a shop needs.
The decision, in order
- Can it come from a server component? Do that.
- Should it survive a refresh or be shareable? URL.
- Is it only one component's concern?
useStatethere. - Do a few nearby components need it? Lift it and pass props.
- Is it genuinely global? Context.
- Is Context measurably too slow? Zustand.
Most state stops at step 1 or 2. Starting at step 5 is the common mistake.
Check your work
The four kinds: server, URL, form and UI state. Only the last two are truly client state.
Why filters belong in the URL: so they survive a refresh, work with the Back button, and can be shared by copying the address.
Why delete a param rather than set it to "all": keeps URLs clean and means one canonical URL for the unfiltered view — which also matters for SEO.
Why reset page when a filter changes: otherwise the user stays on page 4
of a result set that now has one page.
Why useCart throws when there is no provider: it fails with a message that
names the problem instead of a null error somewhere unrelated.
Why the provider should not be in the root layout: it would make the whole application a client component and ship all of it to the browser.
What Context costs: every consumer re-renders when any part of the value changes.
The order: server component → URL → local state → lifted state → Context → library.
Practice
- Build a category filter using
useState. Refresh and watch it reset. - Rewrite it using
searchParamsandrouter.push. Confirm refresh and Back both work. - Copy the filtered URL into another tab and confirm you see the same results.
- Add pagination and confirm changing a filter resets to page 1.
- Build a filter drawer with local state. Show that nothing outside needs to know it is open.
- Build the cart Context. Add items from a product card and show the count in the header.
- Use
useCartoutside the provider and read your own error. - Put the provider in the root layout, then move it to the shop layout, and compare the client bundle size.
- Add an unrelated flag to the cart context and log renders in a product card to see the extra re-renders.
That is module four. You can style with Tailwind without looking anything up, you have components to build with, a grid that survives a real phone, forms everyone can use, and a way to decide where state lives.
Next module: the back end.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship