Post 03
When to Reach for a State Manager (and When Not To)
Half the Redux stores I have deleted were holding server data that should have been a cache, and form state that should have been local. Here is a straight answer on where different kinds of state belong, with code for each.

“Which state manager should we use” is usually the wrong first question. The right one is “what kinds of state does this app actually have,” because they belong in different places and only one of them is what a state manager is for.
Four kinds of state
| Kind | Example | Where it belongs |
|---|---|---|
| Server state | The list of projects, the current user | A data cache — RSC, React Query |
| URL state | Filters, tab, page, sort, search query | The URL — searchParams |
| Local UI state | Is this menu open, this input’s value | useState in the component |
| Shared client state | Theme, a multi-step wizard, a cart pre-checkout | A state manager — or Context |
Server state: use a cache, not a store
If the data comes from your backend, it is a cache of something that lives elsewhere. In the App Router a lot of it never needs client state at all — it is fetched in a Server Component and passed down. For the interactive parts, React Query handles dedupe, revalidation, and optimistic updates.
1"use client";2import { useMutation, useQueryClient } from "@tanstack/react-query";3import { toggleStar } from "@/app/actions/star";4 5export function StarButton({ repo }: { repo: Repo }) {6 const qc = useQueryClient();7 const { mutate } = useMutation({8 mutationFn: () => toggleStar(repo.id),9 onMutate: async () => {10 await qc.cancelQueries({ queryKey: ["repo", repo.id] });11 const prev = qc.getQueryData<Repo>(["repo", repo.id]);12 qc.setQueryData(["repo", repo.id], { ...repo, starred: !repo.starred });13 return { prev };14 },15 onError: (_e, _v, ctx) => qc.setQueryData(["repo", repo.id], ctx?.prev),16 onSettled: () => qc.invalidateQueries({ queryKey: ["repo", repo.id] }),17 });18 return <button onClick={() => mutate()}>{repo.starred ? "★" : "☆"}</button>;19}If your global store has a loading: true and an error: null next to an array of things from your API, you have reimplemented a worse version of React Query. Delete it.
URL state: put it in the URL
Anything that describes what the user is looking at — the active filter, the current page, the search term — belongs in searchParams. Then it is shareable, bookmarkable, survives refresh, and works with the back button for free.
1"use client";2import { useQueryStates, parseAsInteger, parseAsStringEnum } from "nuqs";3 4const STATUSES = ["all", "open", "closed"] as const;5 6export function useFilters() {7 return useQueryStates({8 status: parseAsStringEnum([...STATUSES]).withDefault("all"),9 page: parseAsInteger.withDefault(1),10 q: parseAsStringEnum([]).withDefault(""),11 });12}13 14// a Server Component can read the same values straight off `searchParams`Local state: keep it local
The value of a text input, whether a disclosure is open, a hover state — this is useState in the component that owns it. Lifting it “in case something else needs it” is speculative and usually wrong.
Shared client state: now a state manager earns its place
What is left is genuinely global, genuinely client-side, and changes over time: theme, a feature-flag override, a multi-step flow that spans routes, a cart that exists before checkout. This is small, and Context often covers it. If Context re-renders become a problem, reach for Zustand.
1import { create } from "zustand";2import { persist } from "zustand/middleware";3 4interface CartState {5 items: CartItem[];6 add: (item: CartItem) => void;7 remove: (id: string) => void;8 clear: () => void;9 total: () => number;10}11 12export const useCart = create<CartState>()(13 persist(14 (set, get) => ({15 items: [],16 add: (item) =>17 set((s) => ({ items: [...s.items, item] })),18 remove: (id) =>19 set((s) => ({ items: s.items.filter((i) => i.id !== id) })),20 clear: () => set({ items: [] }),21 total: () => get().items.reduce((n, i) => n + i.price * i.qty, 0),22 }),23 { name: "cart" }, // survives reload, syncs across tabs24 ),25);Selectors keep re-renders tight: const count = useCart((s) => s.items.length) only re-renders when the length changes, not on every cart mutation.
The rule of thumb
- From the server? → data cache.
- Describes the current view? → URL.
- Only one component cares? →
useState. - Actually global, actually client, actually stateful? → Context, then Zustand if it hurts.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.