Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Scaling Stores with the Slice Pattern

Hello! Welcome back to our course on Zustand.

In our last lesson, we explored how to implement asynchronous actions to fetch and manage data from remote APIs. We saw how Zustand's simplicity allows for async/await syntax directly in your store actions, without needing any special middleware.

As you start building more features, each with its own state and async logic (e.g., fetching user profiles, managing a shopping cart, handling UI state), your single store file can become very large and difficult to manage. Today, we'll tackle this exact problem.

This lesson addresses the learning outcome: Organize a large store using the 'slice pattern' for improved scalability and maintainability. We will learn how to break down a monolithic store into smaller, modular, and feature-focused "slices," making your codebase cleaner, more organized, and easier to collaborate on.

1. The Problem: The Monolithic Store

Imagine an application with authentication, user profiles, and a product catalog. A single store file might look like this:

// A large, monolithic store - hard to maintain!
const useAppStore = create((set, get) => ({
  // Auth state
  token: null,
  isAuthenticated: false,
  // Auth actions
  login: async (credentials) => { /* ... */ },
  logout: () => { /* ... */ },

  // Profile state
  profile: null,
  isLoadingProfile: false,
  // Profile actions
  fetchProfile: async () => { /* ... */ },
  updateProfile: async (data) => { /* ... */ },

  // Product state
  products: [],
  isLoadingProducts: false,
  // Product actions
  fetchProducts: async () => { /* ... */ },
  // ...and so on
}));

As the application grows, this single create block becomes a bottleneck. It's hard to read, and multiple developers working on different features (like auth and products) are likely to cause merge conflicts in the same file.

The solution is to modularize the store by feature, which is what the slice pattern enables.

This diagram illustrates the target architecture. A single Zustand store is composed of multiple, logical 'slices' (e.g., for different business domains). React components interact with this single store, but the underlying logic is neatly organized and separated.

2. Implementing the Slice Pattern

The "slice pattern" isn't a special API in Zustand; it's a convention for organizing your code. The core idea is to define each part of your store in a separate function and then combine them into one final store.

Let's see how this is done using the official Zustand documentation.

Slices Pattern - Zustand

The official Zustand documentation provides the canonical example of the slice pattern. It's the best place to start to understand the fundamental structure.

Please read the first section, 'Slicing the store into smaller stores'. Focus on how createFishSlice and createBearSlice are defined as simple functions. Then, observe how they are combined into useBoundStore using the spread operator (...).

Let's break down the key concepts from that example:

  • Slice Creator Function: A slice is just a function that accepts the set and get arguments (and the api object, which is less commonly used) and returns an object containing that slice's state and actions.

    export const createBearSlice = (set) => ({
      bears: 0,
      addBear: () => set((state) => ({ bears: state.bears + 1 })),
      // ...
    })
    
  • Combining Slices: In your main store file, you import these slice creators and combine them inside the create function. The (...a) syntax captures all arguments passed by create (which are set, get, api) into an array a, and the spread syntax ...a passes them along to each slice creator.

    import { create } from 'zustand'
    import { createBearSlice } from './bearSlice'
    import { createFishSlice } from './fishSlice'
    
    export const useBoundStore = create((...a) => ({
      ...createBearSlice(...a),
      ...createFishSlice(...a),
    }))
    

This structure gives you clear separation. The logic for bears is in bearSlice.js, and the logic for fish is in fishSlice.js.

3. Using the Sliced Store in Components

A major benefit of this pattern is that from a component's perspective, nothing changes. You still interact with a single, unified store hook.

The documentation you just read also shows how to use the combined store. In a component, you simply import useBoundStore and select the state or actions you need, regardless of which slice they came from.

import { useBoundStore } from './stores/useBoundStore'

function BearCounter() {
  // 'bears' comes from the bear slice
  const bears = useBoundStore((state) => state.bears)
  return <h1>{bears} Bears</h1>
}

function FishCounter() {
  // 'fishes' comes from the fish slice
  const fishes = useBoundStore((state) => state.fishes)
  return <h1>{fishes} Fishes</h1>
}

The implementation is an internal detail; the component's API remains simple and consistent.

4. Cross-Slice Interactions

What if an action in one slice needs to affect another? For example, what if a bear eats a fish? The eatFish action in the bearSlice needs to decrease the fishes count in the fishSlice.

Zustand handles this gracefully because the set function's updater always receives the entire combined state.

Looking back at the createBearSlice from the documentation (6b1db):

export const createBearSlice = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })), // <-- Updates fish slice!
})

The eatFish action can directly set fishes because the state object it receives is the full state of the bound store.

For more complex interactions, especially calling an action from another slice, you can use the get() function.

Slices Pattern - Zustand

The Zustand docs also show a pattern for composing actions from different slices.

Please read the section 'Updating multiple stores'. Notice how the createBearFishSlice uses get() to call actions defined in other slices (get().addBear() and get().addFish()).

This pattern is extremely powerful for creating complex workflows that span multiple domains of your state, while still keeping the source code for each domain neatly organized in its own file.

5. The Slice Pattern with TypeScript

For professional development, using TypeScript is standard practice. The slice pattern works beautifully with TypeScript, but it requires defining the types for each slice and the combined store.

Understanding Zustand: Real life example with slice pattern

This blog post provides a clear, practical example of implementing the slice pattern with TypeScript.

Please read the section 'Setting Up the Zustand Store with Slices'. Pay close attention to the use of StateCreator. This utility type from Zustand is used to type the slice creator function itself, ensuring that the arguments (set, get, api) and the return type are correct.

The key takeaways for TypeScript are:

  1. Define an interface for each slice's state and actions (e.g., ContactFormSlice).
  2. Define a combined interface for the entire store (e.g., interface Store extends ContactFormSlice, AnotherSlice {}).
  3. Use the StateCreator<Store, [], [], ContactFormSlice> generic to type your slice creator function. This tells TypeScript that this function creates a ContactFormSlice part of the larger Store.

This approach provides full type safety and autocompletion across your entire store, even when it's split into many files.

Conclusion

You now have a powerful strategy for keeping your Zustand store organized, scalable, and maintainable as your React application grows. The slice pattern is a convention, not a rigid framework, giving you the flexibility to structure your state management in a way that best fits your project's architecture.

Key Takeaways:

  • Monolithic stores are hard to maintain. The slice pattern solves this by organizing state logic by feature.
  • Slices are creator functions. Each slice is a function that takes (set, get) and returns an object of state and actions.
  • Combine slices with the spread operator. A single create((...a) => ({ ...slice1(...a), ...slice2(...a) })) call brings everything together.
  • The component API is unchanged. Components interact with a single hook, unaware of the underlying slice structure.
  • Cross-slice updates are possible. Use set(state => ...) to update other slices' state or get().otherAction() to call actions from other slices.

In our next lesson, we will explore middleware. We'll see how middleware can wrap your store to add powerful, cross-cutting functionalities like connecting to the Redux DevTools for debugging (devtools) and persisting state to localStorage (persist). You've already had a small preview of how middleware is applied to a combined store, and next time we'll dive deep into how it works.

Can't find a good explanation? Sign up and we'll make it for you

Sign up