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
StateCreatorsignature that returns a partial state object. - Slices are combined by spreading them into a single
createcall. The resulting store has all properties from all slices. StateCreator<CombinedStore, [], [], SliceType>generic allows a slice to access state from other slices viaget().- The
setandgetparameters 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 meansget()will not have its properties. - The
StateCreatorgeneric 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
| Approach | Pros | Cons |
|---|---|---|
| Slice pattern | Modular, cross-slice access possible | Complex TypeScript generics |
| Separate stores | Truly independent, simpler types | No shared state, cross-store access requires getState() |
| Single large store | No combining needed | Hard to maintain at scale |
| Redux Toolkit slices | Familiar pattern, built-in tooling | Redux boilerplate |
FAQs
What is a slice in Zustand and why would you use one?
- A slice is a function with the
StateCreatorsignature 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
createcall.
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:
immercan 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/getfrom 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.