React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandslicesmodularcombining

Slice Pattern

Recipe

Split large stores into focused slices, then combine them into a single store. Each slice defines its own state and actions independently.

// stores/slices/user-slice.ts
import { StateCreator } from "zustand";
 
export interface UserSlice {
  user: { name: string; email: string } | null;
  setUser: (user: { name: string; email: string }) => void;
  clearUser: () => void;
}
 
export const createUserSlice: StateCreator<UserSlice> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null }),
});
// stores/slices/theme-slice.ts
import { StateCreator } from "zustand";
 
export interface ThemeSlice {
  theme: "light" | "dark";
  toggleTheme: () => void;
}
 
export const createThemeSlice: StateCreator<ThemeSlice> = (set) => ({
  theme: "light",
  toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
});
// stores/app-store.ts
import { create } from "zustand";
import { createUserSlice, UserSlice } from "./slices/user-slice";
import { createThemeSlice, ThemeSlice } from "./slices/theme-slice";
 
type AppStore = UserSlice & ThemeSlice;
 
export const useAppStore = create<AppStore>()((...args) => ({
  ...createUserSlice(...args),
  ...createThemeSlice(...args),
}));

Working Example

// stores/slices/cart-slice.ts
import { StateCreator } from "zustand";
import type { AuthSlice } from "./auth-slice";
 
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}
 
export interface CartSlice {
  items: CartItem[];
  addItem: (item: Omit<CartItem, "quantity">) => void;
  removeItem: (id: string) => void;
  getCartTotal: () => number;
  checkout: () => Promise<void>;
}
 
// Cross-slice access via the combined store type
export const createCartSlice: StateCreator<
  CartSlice & AuthSlice,
  [],
  [],
  CartSlice
> = (set, get) => ({
  items: [],
 
  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        };
      }
      return { items: [...state.items, { ...item, quantity: 1 }] };
    }),
 
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
 
  getCartTotal: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
 
  checkout: async () => {
    const { items } = get();
    const token = get().token; // Accessing AuthSlice state
 
    await fetch("/api/checkout", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({ items }),
    });
 
    set({ items: [] });
  },
});
// stores/slices/auth-slice.ts
import { StateCreator } from "zustand";
 
export interface AuthSlice {
  token: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}
 
export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
  token: null,
 
  login: async (email, password) => {
    const res = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
    const { token } = await res.json();
    set({ token });
  },
 
  logout: () => set({ token: null }),
});
// stores/app-store.ts
import { create } from "zustand";
import { createCartSlice, CartSlice } from "./slices/cart-slice";
import { createAuthSlice, AuthSlice } from "./slices/auth-slice";
 
type AppStore = CartSlice & AuthSlice;
 
export const useAppStore = create<AppStore>()((...args) => ({
  ...createAuthSlice(...args),
  ...createCartSlice(...args),
}));
// components/cart.tsx
"use client";
 
import { useAppStore } from "@/stores/app-store";
 
export function Cart() {
  const items = useAppStore((s) => s.items);
  const removeItem = useAppStore((s) => s.removeItem);
  const checkout = useAppStore((s) => s.checkout);
  const getCartTotal = useAppStore((s) => s.getCartTotal);
 
  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name} x{item.quantity}</span>
          <span>${(item.price * item.quantity).toFixed(2)}</span>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <p>Total: ${getCartTotal().toFixed(2)}</p>
      <button onClick={checkout}>Checkout</button>
    </div>
  );
}

Deep Dive

How It Works

  • A slice is a function with the StateCreator signature that returns a partial state object.
  • Slices are combined by spreading them into a single create call. The resulting store has all properties from all slices.
  • StateCreator<CombinedStore, [], [], SliceType> generic allows a slice to access state from other slices via get().
  • The set and get parameters in each slice refer to the combined store, not just the slice.
  • Slices are evaluated once during store creation. The order of spreading does not affect behavior since they all share the same set/get.

Variations

Slice with middleware:

import { devtools, persist } from "zustand/middleware";
 
const useStore = create<AppStore>()(
  devtools(
    persist(
      (...args) => ({
        ...createCartSlice(...args),
        ...createAuthSlice(...args),
      }),
      { name: "app-store" }
    )
  )
);

Independent stores instead of slices:

// Separate stores for truly independent domains
export const useCartStore = create<CartState>((set) => ({ ... }));
export const useAuthStore = create<AuthState>((set) => ({ ... }));
 
// Cross-store access
const token = useAuthStore.getState().token;

TypeScript Notes

  • StateCreator<FullStore, Middleware, Middleware, SliceType> is the key type for slices that need cross-slice access.
  • When slices do not access other slices, StateCreator<SliceType> is sufficient.
  • The combined store type is the intersection of all slice types.
import { StateCreator } from "zustand";
 
// Simple slice (no cross-access)
export const createThemeSlice: StateCreator<ThemeSlice> = (set) => ({ ... });
 
// Cross-access slice
export const createCartSlice: StateCreator<
  CartSlice & AuthSlice,  // Full store type
  [],                      // No mutators (middleware)
  [],                      // No mutators
  CartSlice                // This slice's type
> = (set, get) => ({ ... });

Gotchas

  • State property names must be unique across all slices. If two slices define isLoading, one will overwrite the other silently.
  • Cross-slice access is typed via the first generic of StateCreator. Forgetting to include another slice's type means get() will not have its properties.
  • The StateCreator generic with four type parameters is required for slices that use middleware or cross-access. The two-parameter form does not work.
  • Splitting too aggressively into many tiny slices adds complexity without benefit. Slice when a store grows beyond 15-20 properties or when team members work on different features.
  • Middleware must wrap the combined store, not individual slices (except for rare cases with immer at the slice level).

Alternatives

ApproachProsCons
Slice patternModular, cross-slice access possibleComplex TypeScript generics
Separate storesTruly independent, simpler typesNo shared state, cross-store access requires getState()
Single large storeNo combining neededHard to maintain at scale
Redux Toolkit slicesFamiliar pattern, built-in toolingRedux boilerplate

FAQs

What is a slice in Zustand and why would you use one?
  • A slice is a function with the StateCreator signature that returns a partial state object.
  • Use slices to split a large store into focused, independently maintained modules.
  • Combine them by spreading into a single create call.
How do you combine multiple slices into one store?
type AppStore = UserSlice & ThemeSlice;
 
export const useAppStore = create<AppStore>()((...args) => ({
  ...createUserSlice(...args),
  ...createThemeSlice(...args),
}));
How does a slice access state from another slice?
  • Use the get() function, which references the combined store.
  • Type the slice with StateCreator<FullStore, [], [], SliceType> so TypeScript knows about the other slice's properties.
Gotcha: What happens if two slices define a property with the same name?
  • One silently overwrites the other because slices are spread into a single object.
  • There is no warning. Ensure all property names are unique across slices.
Gotcha: Where should middleware be applied -- on individual slices or the combined store?
  • Middleware must wrap the combined store, not individual slices.
  • Exception: immer can sometimes be used at the slice level, but this is rare.
const useStore = create<AppStore>()(
  devtools(persist((...args) => ({
    ...createCartSlice(...args),
    ...createAuthSlice(...args),
  }), { name: "app-store" }))
);
When should you use separate stores instead of slices?
  • When the domains are truly independent and never need to share state.
  • Separate stores have simpler types but require getState() for cross-store access.
Does the order of spreading slices matter?
  • No. All slices share the same set/get from the combined store.
  • The order only matters if there are duplicate property names (last one wins).
How do you type a slice that does NOT access other slices in TypeScript?
import { StateCreator } from "zustand";
 
const createThemeSlice: StateCreator<ThemeSlice> = (set) => ({
  theme: "light",
  toggleTheme: () => set((s) => ({
    theme: s.theme === "light" ? "dark" : "light",
  })),
});
How do you type a slice that accesses another slice in TypeScript?
const createCartSlice: StateCreator<
  CartSlice & AuthSlice, // Full store type
  [],                     // Mutators in
  [],                     // Mutators out
  CartSlice               // This slice's type
> = (set, get) => ({
  // get().token is now typed from AuthSlice
});
What is the combined store's type when using slices?
  • The combined type is the intersection of all slice types: type AppStore = SliceA & SliceB & SliceC.
  • This is passed as the generic to create<AppStore>().
At what point should you split a store into slices?
  • When the store grows beyond 15-20 properties.
  • When different team members work on different features within the same store.
  • Do not split too aggressively -- tiny slices add complexity without benefit.