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.

Mar 11, 2025/9 min readFrontend
ShareY
When to Reach for a State Manager (and When Not To)

“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

KindExampleWhere it belongs
Server stateThe list of projects, the current userA data cache — RSC, React Query
URL stateFilters, tab, page, sort, search queryThe URL — searchParams
Local UI stateIs this menu open, this input’s valueuseState in the component
Shared client stateTheme, a multi-step wizard, a cart pre-checkoutA 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.

optimistic update — the reason to use the library
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}
Watch out

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.

typed URL state with nuqs
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.

stores/cart.ts
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);
Tip

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

  1. From the server? → data cache.
  2. Describes the current view? → URL.
  3. Only one component cares? → useState.
  4. Actually global, actually client, actually stateful? → Context, then Zustand if it hurts.

Found this useful? Pass it on.

All posts

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.