SN.
All Articles
EngineeringAugust 21, 20248 min read

Before Zustand: Deciding Where React State Actually Belongs

Before adding a store, I classify state by ownership, lifetime, and consumers: local UI, URL, server-owned data, or genuinely shared client state.

Dark developer workspace with React source code open on a laptop

Before I add a store to a React project, I want three things to be clear: who owns the state, who consumes it, and how long it needs to live.

Those questions are usually more important than whether the team prefers Zustand, Context, or another state library. A stronger tool does not fix incorrect ownership; it only lets the incorrect model spread further.

State management starts with modeling the data, not choosing the package.

I classify state by its source of truth

A modal being open is not the same kind of state as dashboard data, a filter encoded in the URL, or a shopping cart.

State typeExampleTypical home
Local UIModal, accordion, hover, small draftComponent
URL stateSearch, filters, sort, paginationSearch params / route
Server stateProducts, orders, dashboard dataData-fetching / cache layer
Shared client stateShared selections, UI preferences, temporary cart stateContext or store
Form stateSimple or multi-step formsComponent, form layer, or scoped store

This is not a rigid rulebook. It is a guardrail against moving every value used by two components into a global store.

Five questions I ask before creating a store

1. Where is the source of truth?

If the data comes from an API, the server owns the canonical value. Copying that response into a client store and manually reimplementing loading, errors, refreshes, and invalidation creates two sources of truth very quickly.

2. How many parts of the interface actually mutate it?

If one component and a few nearby children use the value, local state or lifting state to the closest common owner is usually enough.

3. Should it survive navigation?

An open dropdown generally does not need to. Product filters might. A cart has a completely different lifetime again.

4. Should the user be able to share or bookmark this state?

When search, filters, sorting, or pagination define the view, the URL is often a better state container than a client store. Browser back/forward behavior comes for free, and a copied URL can reconstruct the screen.

5. Does it need persistence?

Persistence is a separate requirement, not proof that state should be global. localStorage, cookies, session storage, and server persistence all have different lifecycles and security implications.

A modal usually does not need a store

Consider a product page with a size-guide modal. If only that page opens and closes it, the ownership is obvious:

const [isSizeGuideOpen, setSizeGuideOpen] = useState(false);

Moving that boolean into a global store gives the project a new API without solving a real coordination problem.

Now change the requirement: an authentication dialog can open from the header, a pricing card, and checkout. Shared client state becomes reasonable because several distant consumers coordinate the same behavior.

The deciding factor is not that both examples are modals. It is their ownership and scope.

Shareable filters usually belong in the URL

Product filters are often moved into a store too early. I first ask whether refreshing the page should preserve the current view and whether a copied URL should restore it.

If yes, search params are a natural fit:

/products?category=shoes&sort=price-desc&page=2

This is useful beyond SEO. The link is shareable, back/forward works as expected, and the state stays aligned with routing.

A client store can still own temporary UI such as whether the mobile filter drawer is open. The selected query does not need to be duplicated in both the URL and the store.

I keep server state out of a generic client store

Orders, inventory, account data, and dashboard metrics come from a server and can become stale. They need fetching, caching, retries, refetching, and invalidation semantics.

Putting them in a plain client store usually means rebuilding parts of a data-fetching layer by hand.

For a dashboard, I prefer the query/loader layer to own server data while the component owns view state such as the active tab or selected range. Refreshing data and changing UI state then remain separate concerns.

A cart is deliberately a gray area

A cart is not automatically “client state.”

A guest cart may begin in the browser. After sign-in, the server may become the source of truth. Some products use a server-backed cart from the first item.

The right model depends on requirements such as:

  • Does the cart need to sync across devices?
  • Are price and stock revalidated on the server?
  • How does a guest cart merge after authentication?
  • Do we need optimistic UI while the server confirms changes?

A store can make the interface responsive, but the client copy should not become the final authority for price or availability when the server owns those rules.

Authentication UI is not authorization

Another common mistake is storing isAuthenticated = true and treating it as a security boundary.

The client can keep a snapshot of the current user so the header or menu renders correctly. The trusted session still needs to come from a source the browser cannot forge into permission.

If a route or action is role-protected, authorization must be enforced outside a mutable client state container. A store can improve UX; it should not be the proof that access is allowed.

When Context is enough

Context works well for dependencies or state with a clear subtree boundary: theme, locale, a service, or a feature-specific value are common examples.

Problems start when one AppContext becomes the home for unrelated values that update at different frequencies. Consumers become coupled to a provider that no longer represents one responsibility.

I would rather split contexts by responsibility than treat Context as a global object.

A store becomes more compelling when the state is genuinely shared and mutable, consumers are far apart, and selectors can subscribe each component to only the slice it needs.

I use Zustand when the store can stay small

Zustand is useful to me when shared browser-owned state needs a compact API. It is not a way to skip the ownership discussion.

A product comparison feature, for example, can have a narrow contract:

type CompareState = {
  ids: string[];
  add: (id: string) => void;
  remove: (id: string) => void;
  clear: () => void;
};

If that same store gradually absorbs fetched product records, the auth session, toasts, modals, form drafts, and theme state, the problem is not Zustand. The feature boundaries have disappeared.

Multi-step forms depend on the flow boundary

Multi-step forms are a good example because there is no universal answer.

If every step renders under one parent, the form can often remain there. If steps are separate routes, navigation and persistence become part of the design. If multiple independent screens edit the same draft, a store scoped to the flow may be appropriate.

Before choosing the container, I define:

  • Which fields survive between steps?
  • Is validation per-step or final?
  • Should browser back preserve the draft?
  • What should happen after a refresh?
  • Is the draft persisted to the server or only in the browser?

Those requirements determine the architecture. The popularity of a library does not.

Failure modes I try to avoid

The store becomes a dumping ground

New state goes into the store because “we already have one.” Eventually no feature owns its own behavior.

Server data is copied into client state

The response exists in a cache layer and a second version is maintained in a store. Synchronization bugs become permanent work.

URL state is duplicated

Filters live in search params and the store. Now every update needs ordering rules, and refresh behavior becomes another synchronization problem.

Everything is persisted

Temporary UI ends up in localStorage. Old schema, migrations, and hydration become complexity the original requirement never asked for.

Actions are too generic

Every component calls a generic setState({ ... }), which spreads business rules through the component tree. Named, narrow actions usually make ownership easier to understand.

My state-management checklist

Before adding a store, I ask:

  • Which feature or source owns this state?
  • Is it local, URL, server, or shared client state?
  • How many consumers read it, and how many places mutate it?
  • Should it survive routing, refreshes, or another device?
  • Does the data already exist in a server cache?
  • Would a small Context solve the problem cleanly?
  • Can the store API stay feature-specific and narrow?
  • How hard would it be to remove the store later?

Architecture before library

Good state architecture usually means keeping state in the smallest scope where it remains correct.

Sometimes that is useState. Sometimes it is the URL, a data-fetching layer, Context, or Zustand. The best choice is not the tool with the most capability. It is the one that makes ownership clearer and creates the least synchronization work for the product.