We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide11 min read

Zustand 5, state without ceremony and selector traps

Zustand holds application state with no providers and no actions. Version 5, selectors returning new references, and migrating without render loops.

Zustand 5, state without ceremony and selector traps

Zustand holds shared application state in an ordinary object you reach from a component with one call. There is no provider wrapping the tree, no action types, no reducers. The current release is 5.0.14, under the MIT licence.

The library's essence fits in a dozen or so lines, and that is its main argument. The rest of this text concerns two things that cause real trouble with it: choosing selectors, and the changes version five brought.

The basic arrangement

Code
TypeScript
import { create } from 'zustand'

type Cart = {
  items: Item[]
  add: (item: Item) => void
  clear: () => void
}

export const useCart = create<Cart>((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}))

In a component you reach for it directly:

Code
TypeScript
const count = useCart((s) => s.items.length)
const add = useCart((s) => s.add)

Two things deserve noticing straight away.

The first: the functions changing state live in the same object as the data. There is no separate action layer and no place to register an event type. That is the whole ceremony this library does not require, and the main reason people reach for it.

The second is subtler and more important: set merges shallowly. You return only the changed fields and the rest stays. With nested state that means replacing an inner object requires spreading it by hand, since merging does not descend past one level.

Selectors, where the trouble is born

This is the one thing to understand properly for this library to behave predictably.

A selector decides when a component re-renders. Reaching for the whole state makes the component react to every change of anything, including a field it never uses. Reaching for one field limits that to changes of that field.

Code
TypeScript
const state = useCart()

const count = useCart((s) => s.items.length)

The first line is the most common reason an application renders more often than it should, and the easiest to fix.

A more serious problem appears when a selector returns something new on every call.

Code
TypeScript
const { items, add } = useCart((s) => ({
  items: s.items,
  add: s.add,
}))

That form looks natural and is a trap. The selector builds a new object every time, so a reference comparison always fails, the component re-renders, the selector builds another object, and round it goes. In version five that ends in a loop, since the library now rests directly on a React mechanism requiring stable results.

Two answers exist. You can reach for each field separately, which is simplest and usually enough. You can also use a shallow comparison helper when you genuinely need several fields at once.

Code
TypeScript
import { useShallow } from 'zustand/shallow'

const { items, add } = useCart(
  useShallow((s) => ({ items: s.items, add: s.add }))
)

The same principle covers filtering selectors. An expression returning a filtered array builds a new array on every render, so it needs the same treatment or the filtering moved outside the selector.

What version five changed

Migrating from the previous version is short, provided you know what to look at.

Default exports disappeared, so imports have to become named. That change is visible, since the compiler reports it immediately.

Support for React older than eighteen disappeared. That lets the library rest directly on the built in subscription mechanism instead of a shim package, which simplifies dependencies and shortens the code.

The store creation function stopped accepting a custom comparison function. If you used that, two routes exist: a store creation variant taking a comparison function, available under a separate import path, or moving to a shallow comparison helper at the selector. The second route is usually better, since it keeps the decision at the point of use.

The most important part, though, is a behavioural change rather than an interface one. A selector returning a new reference now causes a loop rather than merely redundant rendering. Code that previously ran inefficiently can now stop working altogether.

Practical advice on ordering: before moving to five, update to the latest four. That version prints warnings about everything due to disappear without breaking the application, so you get a list of places to fix before anything breaks.

Typing and the shape of a store

The store type gets described once and the library derives the rest, though one detail trips most people.

The type parameter goes on the call that creates the store rather than on its result. The form requires a double call in the layered variant, which looks odd and has a reason: it lets the compiler carry the type through every applied layer instead of losing it along the way.

It also pays to separate data from functions in the type. A structure where state fields sit apart from the functions changing them makes persistence easier, since stating what to store becomes obvious. Mixing both in one flat object works and stops being readable around twenty fields.

Derived value getters are a separate matter. Putting them in the store beside the data is tempting, and then they become part of the state and take part in comparisons on every change. A better home is an ordinary function outside the store taking state as an argument, or a selector written separately and used across components.

Selectors written as named constants carry one more advantage. The same function reference used in several places is easier to find during refactoring than an expression inlined into twenty components, and along the way it forces thinking about what that component actually needs.

Testing and work outside components

A store is an ordinary object, so it tests without a rendering environment, and that is an advantage worth using.

State changing logic can be checked by calling a function and reading the result, with nothing mounted. That runs far faster than testing through the interface and catches different bugs, so both kinds of test make sense side by side.

One thing needs attention: a store created at module level is shared across all tests in a file. State from one test enters the next, producing dependencies between tests that surface only once their order changes. The answer is restoring initial state before each test, or creating the store in a factory function.

Outside tests the same mechanism helps in code running beyond the component tree: in a global event handler, in a function processing a socket message, or in code reacting to a URL change. Reads and writes work there with no context, and a subscription lets you react to changes without rendering anything.

Zustand against the alternatives

OptionScopeCeremonyPick it when
ZustandClient stateMinimalState shared across the application
TanStack QueryServer dataMediumAnything arriving from a backend
React contextClient stateSmallA rarely changing value, a theme for instance
Redux with its toolkitClient stateLargeA large team needing strict rules

The second row is the most common misunderstanding about this library and deserves settling plainly. Data coming from a server is not application state. It requires caching, refreshing, retrying on network errors, and invalidating after writes, and holding it in a client store means reimplementing those mechanisms by hand.

The right arrangement is both side by side: a query library for backend data, this library for state existing only in the browser. An open menu, a selected tab, cart contents before submission, the state of a wizard spread across several steps.

The third row deserves consideration too. React context is built in and suffices for values that change rarely. The trouble starts when a value changes often, since then the whole subtree re-renders, and that is precisely the moment you reach for a separate store.

Add ons worth knowing

The library is small and extends through layers wrapped around a store.

Persisting state to browser storage takes one layer. It helps with carts, interface settings, and partially filled forms.

That capability carries two traps. The first is persisting the whole state including fields that should not be persisted, temporary loading flags for instance. Stating exactly what to persist solves it. The second is a change to the state's shape after a new application release: the user holds the old layout in browser storage while the code expects the new one. Without versioning and a migration function the application breaks for people who visited earlier, and you will not reproduce it in a fresh browser.

Connecting to the Redux developer tools gives a change history and time travel. Enable it outside production only, since it adds size and exposes application state.

The layer letting you write changes as though state were mutable is convenient with deeply nested structures. Before reaching for it, ask whether the state truly needs to be that nested, since a flat structure with identifiers solves the problem without an extra dependency.

Patterns that hold up

A few things save trouble over longer work.

Keep state changing functions in the store rather than in components. A component calling set directly scatters logic across the interface and makes testing it separately impossible.

Split stores by area rather than building one for the whole application. A separate store for the cart, another for interface settings. Smaller stores mean fewer connections and fewer reasons to render.

Compute derived values in a selector rather than holding them in state. A cart total computed from the array cannot drift from it; a total stored beside the array can.

For access outside a component, in an event handler or a helper function, use the read method on the store itself rather than calling the hook. Calling a hook outside a component is an error the compiler will not catch.

The last item concerns server rendering. A store created at module level is shared between requests, so in Next.js one user's state can leak to another. With user dependent state the store has to be created per request and supplied through a provider, which is the one case where this library does require wrapping the tree.

Common mistakes

The first is a selector returning a new object or array on every call. In version five that ends in a render loop rather than merely a performance drop.

The second is reaching for the whole state instead of individual fields. The component then reacts to every change, including ones that do not concern it.

The third is holding server data in this store. That produces a manual layer reimplementing caching and refreshing, meaning what a query library does by itself.

The fourth is persisting the whole state without stating what to store. Loading flags and temporary data then return after a page refresh and spoil the first render.

The fifth is not versioning persisted state. After a shape change the application breaks for people who visited earlier while working perfectly in a fresh browser.

The sixth is a store created at module level in a server rendered application. State is then shared between requests and one user's data reaches another.

The seventh is calling the hook outside a component, in a global event handler for instance. The read method on the store itself exists for that, and the wrong usage will not surface as a compile error.

FAQ

How does Zustand differ from Redux?

In the amount of ceremony. There are no action types, no reducers, and no provider wrapping the tree. State changing functions live beside the data, and a component reaches the store directly. Redux offers stricter rules in exchange, which helps in a large team.

Does Zustand replace TanStack Query?

No. This is a tool for state existing only in the browser, while TanStack Query handles data coming from a server, caching and refreshing included. A typical application uses both for different things.

Why does my component fall into a render loop?

Most often because a selector returns a new object or array on every call. Reach for the fields separately, or wrap the selector in the shallow comparison helper available under a separate import path.

How do I move from version four to five?

Update to the latest four first, to see the warnings without breaking anything. Then convert default imports to named ones, raise React to eighteen or newer, and review selectors returning new references.

Does Zustand work with server rendering?

Yes, provided the store is not created at module level for user dependent state. In that case it has to be created per request and supplied through a provider, otherwise state is shared between requests.

Documentation sits on the project site, and the move to version five in the migration guide.