How to Set Up State Management by Not Managing Most of It
Most state management problems are categorization problems. Teams reach for a global store, put everything in it including data that came from an API, and then hand-write caching, loading flags, refetching, and invalidation — reimplementing a cache badly, in application code.
The single most useful move is splitting state by where it actually lives. Server state is a cache of someone else's data. URL state belongs in the URL. Client state is the small remainder that genuinely lives in the browser. Sorting them correctly eliminates most of the work.
Three kinds of state, three different tools
Ask where the source of truth is. That answer, not the size of the app, tells you what to use.
- ›Came from an API and the server owns it: server state. Use a data-fetching library — TanStack Query, SWR, RTK Query. Not a global store.
- ›Should survive a refresh or be shareable as a link (filters, tab, page, search): URL state. Put it in query params.
- ›Genuinely client-only and shared across distant components (theme, sidebar open, a draft): client state. Zustand, Jotai, or Context.
- ›Used by one component and its children: local `useState`. Most state is this, and lifting it prematurely is the usual cause of prop drilling.
- ›If you're writing `isLoading` by hand for an API call, you're rebuilding a data-fetching library. Use one.
The approaches
Server state: TanStack Query, SWR, RTK Query
// best for: Any data that came from an API. This is most of what apps display.
- React
- Vue.js
- Svelte
- Next.js
- Nuxt
- SvelteKit
These treat API data as a cache keyed by query key, and give you loading and error states, deduplication of concurrent identical requests, background refetching, and stale-while-revalidate for free. Adopting one typically deletes more code than it adds.
The conceptual shift is that you don't store the data — you declare what you want and the library manages freshness. Two components requesting the same key share one request and one cache entry, so the prop drilling that global stores were often solving disappears.
The setting that matters most is `staleTime`. Zero, the default in some libraries, means a refetch on every mount, which looks like excessive network traffic. Set it to how long the data is acceptably fresh — often 30 seconds to a few minutes.
They also pair naturally with SSR: hydrate the cache with server-rendered data and the first render has no loading state at all.
// Not stored in a global store. Declared, and the library
// handles freshness, dedup, and background refetch.
const { data, isLoading } = useQuery({
queryKey: ["invoices", { status, page }], // params in the key
queryFn: () => fetchInvoices({ status, page }),
staleTime: 60_000, // default of 0 refetches on every mount
});
// Two components using this key share one request
// and one cache entry — which removes most prop drilling.The gotcha
Copying query results into local state with `useEffect` reintroduces every problem the library solved: the copy doesn't update on background refetch, so the UI shows stale data while the cache is current. Read from `data` directly.
URL state: query params and route segments
// best for: Filters, pagination, sort order, active tab, search terms.
- React
- Next.js
- Nuxt
- SvelteKit
- Vue.js
- Angular
The most consistently underused state container. Putting filters in the URL means the back button works, the page is shareable and bookmarkable, refresh preserves context, and the state survives without any persistence code.
It also composes with server state: include the params in the query key and changing a filter refetches automatically, with each filter combination cached separately, so navigating back to a previous filter is instant.
The practical detail is choosing between `push` and `replace`. A search input that pushes on every keystroke fills history with garbage and breaks the back button. Debounce and use replace for continuous input; push for discrete actions like changing a page or applying a filter.
The gotcha
Reading a query param and copying it into `useState` on mount decouples them, so a browser back navigation changes the URL while the component keeps the old value. Derive the value from the URL on every render instead of snapshotting it.
Client state: Zustand, Jotai, Context, or useState
// best for: The genuinely client-only remainder, which is smaller than you'd expect.
- React
- Vue.js
- Svelte
- Angular
- Next.js
Once server state and URL state are removed, what's left is usually small: theme, whether a sidebar is open, an unsent draft, a multi-step form in progress. That volume doesn't need Redux.
Zustand is the common choice — a hook-based store with no provider, no boilerplate, and selector-based subscriptions so components only re-render when the slice they read changes. Jotai takes an atomic approach that suits fine-grained independent values.
React Context is fine for values that rarely change, like a theme. It is a poor fit for frequently-changing state, because every consumer re-renders on every change regardless of which part they use. A Context holding a fast-updating value is a common and hard-to-spot performance problem.
And most state should just be `useState` in the component that owns it.
The gotcha
Subscribing to the whole store instead of a slice. `const state = useStore()` re-renders the component on every change to any field, so a component reading only `theme` re-renders when an unrelated counter updates. Always select: `useStore(s => s.theme)`.
Common pitfalls
Putting API data in a global store
You then hand-write caching, loading flags, invalidation, and refetching — a worse version of what a data-fetching library gives you. This is the single most common source of state bugs, usually surfacing as stale data after a mutation.
Lifting state higher than it needs to be
State moved to the top to avoid prop drilling causes the whole tree to re-render on every change. Keep state as close to where it's used as possible; if two distant components need it, that's what a store or a shared query key is for.
Deriving state instead of computing it
Storing `filteredItems` in state alongside `items` and `filter` means keeping three things in sync forever. Compute it during render. If it's genuinely expensive, memoize — but measure first, because it usually isn't.
Context for frequently-changing values
Every consumer of a Context re-renders whenever its value changes, with no way to subscribe to part of it. A Context holding fast-moving state re-renders large subtrees continuously. Use a store with selectors instead.
Questions people actually ask
Do I still need Redux?
Rarely. Once server state moves to a query library and URL state moves to the URL, what remains is usually small enough for Zustand or Context. Redux Toolkit is a fine tool, but most apps that reach for it are solving a categorization problem rather than a state problem.
Where should filters and pagination live?
The URL. It makes state shareable, bookmarkable, and back-button-correct for free, and it composes with query caching so each filter combination is cached separately.
How do I avoid prop drilling without a global store?
Often you don't need to: if the data comes from an API, both components can call the same query hook and share the cache entry. For genuinely client-only shared state, a small store is fine. Composition — passing elements as children — solves many remaining cases.