Back to blog
Guides6 min read

Zustand store structure: slices, selectors, and boundaries

What belongs in a Zustand store and what does not, how to split one into slices, and why the App Router needs a per-request store, not a module-level one.

The structural question is ownership, not syntax

Zustand is small enough that the API is not what people get wrong. What goes wrong is scope: a store that started as "a few values" becomes the place every piece of state lands, and then every component re-renders on every change and nobody can say who owns what.

So the first decision is not how to write the store. It is which state belongs in one at all.

  • Component-local — used by one component or a tight group. Keep it in useState. Local state is the easiest kind to delete later, and deletability is the honest measure of whether a boundary is right.
  • Client-global — needed by distant, unrelated parts of the UI. This is what Zustand is for.
  • Server-owned — fetched from a backend. This belongs in a query cache, not a store. It needs caching, invalidation, and refetching, and reimplementing those inside a store is how stores turn into bad HTTP clients.
  • URL state — anything a user should be able to share or reload into. The address bar is the source of truth; copying it into a store creates a second one.
  • Derived state — computed from state you already have. Compute it in a selector rather than storing it, unless the computation is genuinely expensive. Stored derivations drift.

That taxonomy does more for a codebase than any amount of file organisation. In a planned project, stores are declared alongside the screens that read them for exactly this reason — the question "who reads this?" is easier to answer before the code exists than after.

Type the store, because the contract is shared

A store is shared mutable state, which makes it the worst place for an unchecked shape. Zustand's TypeScript form is curried — create<T>()(...) — and the second call is not a typo; it is what lets the initializer's types be inferred.

import { create } from "zustand"

type Theme = "light" | "dark"

type UiStore = {
  theme: Theme
  sidebarOpen: boolean
  setTheme: (theme: Theme) => void
  toggleSidebar: () => void
}

export const useUiStore = create<UiStore>()((set) => ({
  theme: "light",
  sidebarOpen: false,
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}))

Keep actions in the store when they express a rule — anything a second component would otherwise have to reimplement. Keep presentation details in the component. A store that knows about a specific button is a store that changes when the layout does.

Slices split a store without splitting the state

The slices pattern breaks one store definition into focused creators that are spread into a single store. Each slice owns a domain: cart, preferences, editor selection.

import { create, type StateCreator } from "zustand"

type CartSlice = {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
}

type UserSlice = {
  userName: string
  setUserName: (userName: string) => void
}

type AppStore = CartSlice & UserSlice

const createCartSlice: StateCreator<AppStore, [], [], CartSlice> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
})

const createUserSlice: StateCreator<AppStore, [], [], UserSlice> = (set) => ({
  userName: "",
  setUserName: (userName) => set({ userName }),
})

export const useAppStore = create<AppStore>()((...args) => ({
  ...createCartSlice(...args),
  ...createUserSlice(...args),
}))

Note that each slice is typed against the whole AppStore, not just its own fragment. That is deliberate: a slice can read across into another slice's state, which is the entire reason to keep them in one store rather than several.

Which is also the rule for choosing. One store with slices when the domains interact; separate stores when they genuinely do not. Splitting because a file got long produces boundaries that actions then have to cross, and a cross-store action is worse than a long file.

The empty tuples in StateCreator<AppStore, [], [], CartSlice> are the middleware mutators. Once the store is wrapped in immer or persist, those slots have to name them — StateCreator<AppStore, [["zustand/immer", never]], [], CartSlice> — or the slice's set will be typed without the middleware's behaviour.

Selectors decide what re-renders

Subscribe to the narrowest value a component actually uses. useAppStore() with no selector subscribes to the entire store, so every unrelated update re-renders that component.

const userName = useAppStore((state) => state.userName)

Primitives compare by value, so this is stable for free. The trap is returning a new object:

// re-renders on EVERY store update — the object is a new reference each time
const { count, userName } = useAppStore((state) => ({
  count: state.items.length,
  userName: state.userName,
}))

Zustand v5 no longer accepts an equality function as a second argument to the hook. The replacement is useShallow, which wraps the selector:

import { useShallow } from "zustand/react/shallow"

const { count, userName } = useAppStore(
  useShallow((state) => ({
    count: state.items.length,
    userName: state.userName,
  }))
)

Prefer several primitive selectors to one grouped object. Reach for useShallow when grouping genuinely reads better, not by default.

Subscriptions need the middleware that enables them

subscribe runs a callback on store changes without involving rendering — analytics, imperative integrations, syncing to a non-React API.

The plain signature takes a listener only. The selector form requires the subscribeWithSelector middleware, and this is worth stating plainly because the two-argument call fails quietly without it: the store treats your selector as the listener and calls it with the whole state, so the "subscription" appears to work while firing on every change and ignoring the equality function entirely.

import { create } from "zustand"
import { subscribeWithSelector } from "zustand/middleware"

export const useAppStore = create<AppStore>()(
  subscribeWithSelector((set) => ({
    /* … */
  }))
)

// only valid because of the middleware above
const unsubscribe = useAppStore.subscribe(
  (state) => state.items.length,
  (count, previousCount) => {
    trackCartSize(count, previousCount)
  },
  { fireImmediately: false }
)

Always keep the returned unsubscribe function and call it — from a useEffect cleanup inside React, or at teardown outside it. A dropped unsubscribe is a listener that outlives its reason and fires twice after the next remount.

Middleware is store infrastructure

Middleware wraps the store creator, so it is decided once, at creation, for the whole store.

  • persist — for durable preferences the user expects to survive a refresh. Use partialize to save a subset; persisting everything restores stale UI along with the parts you wanted.
  • devtools — worth it in development, and worth naming actions so the timeline is readable.
  • immer — for genuinely nested updates, where spreading gets noisy. Flat updates are clearer without it.
export const useSettingsStore = create<SettingsStore>()(
  devtools(
    persist(
      immer((set) => ({
        theme: "light",
        profile: { displayName: "", emailOptIn: false },
        setTheme: (theme) =>
          set(
            (state) => {
              state.theme = theme
            },
            false,
            "settings/setTheme"
          ),
      })),
      { name: "settings", partialize: (state) => ({ theme: state.theme }) }
    ),
    { name: "SettingsStore" }
  )
)

Order matters and is easy to get backwards: devtools outermost so it observes everything beneath it, persist around the state initializer it should snapshot, immer innermost against the raw set.

Server state stays in the query cache

Keep the UI's decision in Zustand and the fetched result in the query layer. The selected category is client state — the UI owns it. The products are server state — the backend owns them.

export function ProductList() {
  const category = useAppStore((state) => state.selectedCategory)

  const query = useQuery({
    queryKey: ["products", category],
    queryFn: () => fetchProducts(category),
  })

  if (query.isPending) return <Spinner />
  if (query.isError) return <LoadFailed />
  return <ProductGrid products={query.data} />
}

Copying query results into the store creates two sources of truth and puts invalidation back in your hands, which is the job you adopted a query cache to avoid. Store the input to the query, not its output.

The App Router needs a per-request store

A module-level store is created once per process, not once per user. On the server that module is shared across concurrent requests, so a store initialised with one user's data can be read by another's render. This is the one Zustand mistake with a security shape rather than a performance one.

The fix is a vanilla store built per request and handed down through context:

// stores/counter-store.ts
import { createStore } from "zustand/vanilla"

export type CounterStore = { count: number; increment: () => void }

export function createCounterStore(initialCount: number) {
  return createStore<CounterStore>()((set) => ({
    count: initialCount,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))
}
// providers/counter-store-provider.tsx
"use client"

import { createContext, useContext, useRef, type ReactNode } from "react"
import { useStore } from "zustand"

import { createCounterStore, type CounterStore } from "@/stores/counter-store"

type CounterStoreApi = ReturnType<typeof createCounterStore>

const CounterStoreContext = createContext<CounterStoreApi | null>(null)

export function CounterStoreProvider({
  children,
  initialCount,
}: {
  children: ReactNode
  initialCount: number
}) {
  const storeRef = useRef<CounterStoreApi | null>(null)
  storeRef.current ??= createCounterStore(initialCount)

  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}

export function useCounterStore<T>(selector: (state: CounterStore) => T): T {
  const store = useContext(CounterStoreContext)
  if (!store) throw new Error("Missing CounterStoreProvider")
  return useStore(store, selector)
}

The useRef<CounterStoreApi | null>(null) matters under React 19's types, where useRef requires an argument — the no-argument form found in older examples no longer compiles.

Two consequences follow. Server components must not read or write the store; they pass initial values as props to a client provider. And persist needs care under SSR, because storage does not exist on the server — the persisted value arrives after hydration, so a component rendering from it will mismatch unless you use skipHydration and rehydrate deliberately.

Where the files go

stores/
  app-store.ts        # the combined hook
  cart-slice.ts
  user-slice.ts
  selectors.ts        # selectors shared by several components
providers/
  *-store-provider.tsx  # per-request stores
queries/                # server state, kept separate on purpose

Export hooks and named selectors rather than the store internals, so the public surface stays small enough to change behind. This is the same argument as route trees and component boundaries: the structure is worth choosing while it is still cheap, because state is the layer everything else reaches into. Planning which stores exist alongside the routes that read them makes the ownership question concrete before any of it is written.

Frequently asked questions

Should I use one Zustand store or multiple stores?

One store with slices when the domains interact, separate stores when they are genuinely independent. Slices typed against the whole store can read across each other, which is the main advantage of keeping them together. Splitting a store purely because the file grew creates boundaries your actions then have to cross.

How do I stop a Zustand selector from re-rendering the whole component?

Select the narrowest value you need, and prefer primitives, which compare by value. If a selector returns a new object or array, wrap it in useShallow — in v5 the second-argument equality function was removed, so useShallow is the supported form. Calling the hook with no selector subscribes to the entire store.

Is Zustand safe to use with server components?

Not directly. Server components should not read or write a store, and a module-level store on the server is shared across concurrent requests — one user's data can appear in another's render. Create the store per request with createStore and pass it down through a client provider.

Should server data go in a Zustand store?

No, as a default. Fetched data needs caching, invalidation, and refetching, which a query cache already provides. Keep the UI decision that shapes the request — the filter, the sort, the selected id — in the store, and let the query own the result.

When is the Context API enough instead of Zustand?

When the value changes rarely, or when every consumer needs all of it anyway. Context has no selector mechanism, so any change re-renders every consumer — fine for a theme or a locale, expensive for state that updates as the user types. Zustand's value is precisely the narrow subscription.

Application architecture

Continue this learning path

Route trees, layouts, navigation, guards, and the structural decisions that become expensive after implementation begins.

Open the Structure Painter

Design your application

Carry the route tree into a complete project with data, APIs, access rules, tests, and generated scaffolding.

Open projects

We'd like to use Google cookies to understand how Nodlume is used and to measure our advertising. Nothing loads until you choose, and declining does not affect anything in the app.