After years with Redux and time spent trying newer stores, I found that most state problems began before the library choice. We had called several different values “state” and asked one store to own them all.
A selected tab, URL filter, API response, draft form, and authenticated user have different lifetimes, sources of truth, and synchronization rules. An application becomes easier to trace when each value has one clear owner.
Before choosing an API, ask two questions:
- Who owns this value?
- How long should it survive?
My default ownership map is simple:
- React owns local UI such as menus, tabs, and form drafts.
- The URL owns navigable state such as search, filters, and pagination.
- The server and query cache own remote data under explicit freshness rules.
- A small store owns shared client values such as the active workspace.
- A reducer or state machine owns workflows with complex transitions.
State used by one component or subtree belongs near that behavior. A menu can own whether it is open. A form can own its draft and validation. Putting those values in a global store adds names, subscriptions, reset rules, and dependencies across the application. Lift state when two parts of the tree need to coordinate, and keep the owner as narrow as that interaction allows.
Local ownership also makes deletion easier. Removing a component removes its state and transitions with it.
Search terms, filters, selected views, and pagination often describe the page itself. The URL gives those choices browser history, refresh survival, deep links, and sharing. It also gives the router one source of truth for loading the page.
Serialize values that should be navigable. Temporary input, open tooltips, and unfinished form fields can stay local unless the product supports restoring them. Treat parsing and defaults as part of the route contract, and resolve invalid parameters predictably.
API data carries freshness, request identity, retries, cancellation, invalidation, and mutation behavior. I use TanStack Query to own that lifecycle. A query key identifies the data. Freshness rules decide when to reuse it. Mutations update or invalidate the relevant cache. Components render request state without rebuilding a fetching framework inside a general store.
Keep derived views as selectors or calculations when possible. Copy server data into client state when the user begins an independent draft that now has its own lifetime.
After local, URL, and server state have owners, the remaining shared client state is usually small.
Zustand fits this space well:
import { create } from 'zustand'
type WorkspaceState = {
activeId: string | null
setActive: (id: string) => void
}
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
activeId: null,
setActive: (id) => set({ activeId: id }),
}))
The store offers a direct write path and narrow subscriptions. Keep actions near the state they change. Define reset behavior for logout, workspace changes, tests, and server rendering.
Persistence needs extra care because stored client state can outlive the code and schema that created it. Version persisted data and provide a migration or safe reset.
Redux Toolkit remains useful in systems that already depend on it, teams that benefit from strict conventions, and domains that need auditable transitions or mature debugging tools. A stable Redux codebase may gain little from moving to a smaller store. Improve selectors, boundaries, and ownership before changing the foundation.
Reducers also fit complex domain transitions regardless of the surrounding store. Explicit events can make workflows, editors, and undo history easier to reason about.
The library choice gets smaller after every value has a clear owner, lifetime, and source of truth.