How to Structure Large Applications with Multiple Zustand Stores Using the Slices Pattern

Use the slices pattern to split a monolithic Zustand store into small, composable slice creators that are merged into a single bounded store with shared middleware and full type inference.

As applications grow, a single monolithic store in pmndrs/zustand becomes difficult to maintain—state files swell, TypeScript inference slows, and developers lose track of which features own specific state branches. The slices pattern solves this by treating each feature as an isolated slice creator function that receives the store's set and get functions, then combining them into one bounded store. This approach is officially documented in docs/learn/guides/slices-pattern.md and recommended in the README under Best practices for maintainable architecture.

Why Monolithic Stores Fail at Scale

A single create() call containing every piece of global state eventually creates tight coupling between unrelated features. When multiple developers modify the same massive state object, merge conflicts increase and refactoring becomes risky.

The slices pattern provides four key advantages for large codebases:

  • Modularity – Each feature lives in its own file (e.g., src/stores/fishSlice.ts), making it easy to locate, test, and refactor without affecting unrelated code.
  • Type Safety – Slice creators expose strictly typed state and actions that are merged with inferred types via the combine middleware.
  • Middleware Consistency – Apply persist, devtools, or custom middleware once at the root level in useBoundStore.ts rather than duplicating logic across slices, which prevents subtle bugs from conflicting middleware instances.
  • Coordinated Updates – Actions inside one slice can call actions from another slice through the shared get() function, enabling cross-feature workflows without direct imports or tight coupling.

Core Concepts of the Zustand Slices Pattern

Understanding three fundamental building blocks is essential before implementing this architecture.

Slice Creators

A slice creator is a function with the signature (set, get) => { ...state, ...actions }. Unlike standard Zustand stores, it does not call create() itself; instead, it returns a plain object containing state properties and action methods. Because it receives the same set and get functions that the root store receives, slice actions can read or update any part of the combined state, including state owned by other slices.

The combine Middleware

Located at src/middleware/combine.ts, the combine middleware merges an initial static state object with a slice creator function. Internally, it uses Object.assign to produce a new StateCreator that yields the final store shape. This utility allows TypeScript to infer the complete state type from the individual slice return types without manual interface declarations.

Bounded Stores

The bounded store is the final Zustand hook created by calling create() (or createStore for vanilla JS) with all slices spread into the root state object. This single hook exposes the union of every slice's state and actions while maintaining a single source of truth for middleware configuration.

Architectural Layout for Large Applications

Organize your source code so that each slice owns its domain logic, and the store composition happens in a dedicated entry point:


src/
│
├─ stores/
│   ├─ fishSlice.ts      // createFishSlice
│   ├─ bearSlice.ts      // createBearSlice
│   ├─ bearFishSlice.ts  // optional cross-slice actions
│   └─ useBoundStore.ts  // combine slices & apply middlewares
│
└─ components/
    └─ App.tsx           // consumes useBoundStore

This structure ensures that the useBoundStore.ts file remains the single location where middlewares like persist or devtools are attached. When business logic changes, developers modify only the relevant slice file rather than hunting through a thousand-line store definition.

Implementing the Slices Pattern Step by Step

Follow this sequence to refactor an existing monolithic store or start a new project with proper separation of concerns.

Creating Individual Slices

Define each feature's state and actions in isolation. The slice creator receives set for updates and get for reading current state.

// src/stores/fishSlice.ts
export const createFishSlice = (set, get) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
// src/stores/bearSlice.ts
export const createBearSlice = (set, get) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  // Cross-slice action that modifies another slice's state
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})

Handling Cross-Slice Coordination

When an action needs to orchestrate multiple slices without tight coupling, create a dedicated coordination slice that calls other actions via get().

// src/stores/bearFishSlice.ts
export const createBearFishSlice = (set, get) => ({
  addBearAndFish: () => {
    get().addBear()
    get().addFish()
  },
})

Combining Slices and Applying Middleware

In useBoundStore.ts, spread all slice creators into a single object and wrap them with middleware. According to the official documentation in docs/learn/guides/slices-pattern.md, middlewares must only be applied at this top level to avoid duplicated listeners and unexpected state resets.

// src/stores/useBoundStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
import { createBearFishSlice } from './bearFishSlice'

export const useBoundStore = create(
  persist(
    (set, get) => ({
      ...createBearSlice(set, get),
      ...createFishSlice(set, get),
      ...createBearFishSlice(set, get),
    }),
    { name: 'my-app-store' }
  )
)

Consuming the Bounded Store

Components import the single useBoundStore hook and select specific state slices or actions.

// src/components/App.tsx
import { useBoundStore } from '../stores/useBoundStore'

export default function App() {
  const bears = useBoundStore((state) => state.bears)
  const fishes = useBoundStore((state) => state.fishes)
  const addBear = useBoundStore((state) => state.addBear)
  const addBoth = useBoundStore((state) => state.addBearAndFish)

  return (
    <div>
      <h2>Bears: {bears}</h2>
      <h2>Fishes: {fishes}</h2>
      <button onClick={addBear}>Add Bear</button>
      <button onClick={addBoth}>Add Both</button>
    </div>
  )
}

Best Practices for Production Applications

Maintain code quality and performance in large applications by adhering to these guidelines derived from the pmndrs/zustand source code and documentation.

Attach Middlewares Exclusively in the Combined Store

Never apply persist, devtools, or logging middleware inside individual slice files. Applying middleware at the slice level creates multiple subscriber layers and can cause state synchronization issues. The combine implementation in src/middleware/combine.ts expects middleware to wrap the final merged creator.

Keep Slice Creators Pure

Slice creators should return plain JavaScript objects. Side effects such as API calls or localStorage access belong inside action methods, not in the slice creator's outer scope. This purity enables unit testing by allowing you to instantiate temporary stores with createStore without mounting the entire application.

Leverage TypeScript Inference

Declare explicit return types on your slice creators, then let the combine middleware infer the full store shape. This provides autocomplete for state keys and compile-time validation that cross-slice actions exist on the get() object.

Test Slices in Isolation

Because each slice is a pure function of (set, get), you can import a slice creator into a test file, create a temporary store with createStore, and verify action logic without rendering React components or importing the full bounded store.

Summary

  • The slices pattern splits large Zustand stores into feature-focused slice creator functions that are merged into one bounded store.
  • Use the combine middleware from src/middleware/combine.ts to merge initial state with slice creators while preserving type inference.
  • Apply middlewares only once in the root useBoundStore.ts file to avoid duplicated listeners and state inconsistency.
  • Enable cross-slice communication by calling get().otherSliceAction() inside action methods, allowing coordinated updates without tight coupling.
  • Reference docs/learn/guides/slices-pattern.md and the Best practices section of README.md for official architectural guidance from the pmndrs/zustand maintainers.

Frequently Asked Questions

What is the Zustand slices pattern?

The Zustand slices pattern is an architectural approach where you define small, reusable slice creator functions—each responsible for a specific feature's state and actions—and then merge them into a single store using the combine middleware or object spreading. This pattern is officially documented in docs/learn/guides/slices-pattern.md and designed to prevent monolithic state files in large applications.

How do I share state between different slices?

Actions within one slice can access the entire store's state and actions through the get() function passed to the slice creator. For example, an action in bearSlice.ts can call get().addFish() to trigger an update in fishSlice.ts, or directly modify fishes in the set() call. This enables cross-slice updates without importing slice files into each other.

Where should I apply middleware in a sliced store?

Always apply middleware—such as persist, devtools, or immer—in the root store file (e.g., useBoundStore.ts) that combines all slices. According to the Zustand documentation, applying middleware inside individual slice files causes duplicated middleware instances and unexpected behavior. The combined store should be the single point where middleware wraps the final merged state creator.

Can I use the slices pattern with TypeScript?

Yes, the slices pattern is fully compatible with TypeScript and provides excellent type safety. Define return types for your slice creators (e.g., BearSlice), then use combine or explicit type annotations on the bounded store to let TypeScript infer the union of all slice types. The guide in docs/learn/guides/advanced-typescript.md details how to type slices so that get() correctly recognizes actions from other slices at compile time.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →