Zustand Slice Pattern: Separating Large Stores by Domain and Connecting Them Type-Safely with TypeScript
In the early stages of a project, a single Zustand store is enough. Having user, cart, and modal state all in one file isn't much of a problem. But at some point, that store file crosses 500, then 800 lines, and you start to feel it. Team members step on each other's toes in the same file, merge conflicts happen, and tracing exactly where and how logout clears the cart becomes its own chore. I too started out thinking "Zustand is lightweight, one store should be fine" — and eventually lived through a large-scale refactor.
After reading this article, you'll be able to implement a structure that uses the StateCreator generic to separate stores by domain and connect cross-slice actions in a type-safe way at compile time. The goal is to achieve an enterprise-grade state management architecture while keeping Zustand's lightweight feel — without the complexity of Redux Toolkit.
Target audience: This article assumes basic experience with Zustand. If you're not yet comfortable with create(), set(), and get(), it's recommended to read the Getting Started section of the Zustand official docs first. This assumes Zustand v5 and a TypeScript strict mode environment, and also covers how to incrementally migrate from an existing single store.
Core Concepts
Understanding the StateCreator Generic Structure
The idea behind the slice pattern is straightforward. You break one large store into domain-level pieces, but at runtime combine them back into a single unified store. It's conceptually similar to Redux Toolkit's createSlice, but Zustand supports this in a much simpler way.
The key is the StateCreator type. Getting a handle on its generic argument structure first makes the code that follows much easier to read.
StateCreator<
TStore, // The full store type — the key to cross-slice access
TMutators, // Middleware chain info (usually [] or [['zustand/immer', never], never])
[],
TSlice // The type this slice actually provides
>At the slice level, TMutators is usually left as [], and middleware is applied all at once when composing with create(). If you use Immer, [['zustand/immer', never], never] goes here — this is covered in detail in Example 3.
import { StateCreator } from 'zustand';
type AuthSlice = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
// StateCreator<FullStoreType, Mutators, [], ThisSliceType>
const createAuthSlice: StateCreator<
AuthSlice & CartSlice, // First argument: the full store type
[],
[],
AuthSlice // Fourth argument: the type this slice provides
> = (set, get) => ({
user: null,
login: (user) => set({ user }),
logout: () => {
set({ user: null });
get().clearCart(); // Calling a CartSlice action in a type-safe way
},
});Core principle — It all comes down to putting the full store type in the first argument. This is what allows TypeScript to accurately infer types when accessing other slices' state and actions via
get().
Why Cross-Slice Actions Are Needed
Once you understand the structure of StateCreator, a natural question follows: "When a user logs out, the cart should be cleared too — where does that logic live?"
Honestly, this is the most confusing part of the slice pattern. Cross-slice actions are implemented via the get() function. Because get() returns a snapshot of the entire store state, as long as the correct full store type is in the first generic argument, you can call other slices' actions in a type-safe way.
// One-directional dependency example: AuthSlice → CartSlice, NotificationSlice
const createAuthSlice: StateCreator<BoundStore, [], [], AuthSlice> =
(set, get) => ({
user: null,
logout: () => {
set({ user: null });
get().clearCart(); // CartSlice action
get().addNotification('Logged out'); // NotificationSlice action
},
});Unidirectional dependency principle — It's recommended to keep the direction of dependency between slices one-way whenever possible.
A → Bis fine, butA ↔ Bbidirectional references can make debugging difficult due to circular dependency issues.
Composing Slices into a Single Store
Once you understand how cross-slice actions work, the final piece is combining slices into a single store. The composition itself is simple, but there is one important rule: middleware such as devtools, persist, and immer must be applied only once to the composed store, not to individual slices.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
type BoundStore = AuthSlice & CartSlice & UISlice & NotificationSlice;
const useBoundStore = create<BoundStore>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createUISlice(...a),
...createNotificationSlice(...a),
}),
{ name: 'app-store' }
)
)
);In Zustand v5, the create<T>()() double-parentheses pattern has become the standard when using TypeScript with middleware. It looks like a typo at first glance, but it's an intentional pattern that uses currying to make type inference work correctly.
Practical Application
Example 1: Domain-Separated Structure for an E-Commerce App
This is the most common scenario in real-world projects. It's a typical structure where user authentication, a cart, and notifications are interconnected — you can see the full flow from the basics through actual file separation all at once.
Let's start with the file structure.
src/stores/
├── slices/
│ ├── auth.slice.ts
│ ├── cart.slice.ts
│ └── notification.slice.ts
├── types.ts ← BoundStore type definition
└── store.ts ← Slice composition + middlewaretypes.ts — Define the full store type first
// types.ts
export type User = {
id: string;
name: string;
email: string;
};
export type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
};
export type AuthSlice = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
export type CartSlice = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
checkout: () => Promise<void>;
};
export type NotificationSlice = {
notifications: string[];
addNotification: (message: string) => void;
clearNotifications: () => void;
};
// Use this type as the first generic argument in each slice
export type BoundStore = AuthSlice & CartSlice & NotificationSlice;Implementing each slice
// slices/auth.slice.ts
import { StateCreator } from 'zustand';
import { AuthSlice, BoundStore, User } from '../types';
export const createAuthSlice: StateCreator<BoundStore, [], [], AuthSlice> =
(set, get) => ({
user: null,
login: (user: User) => {
set({ user });
get().addNotification(`Welcome, ${user.name}!`);
},
logout: () => {
const { user } = get();
set({ user: null });
get().clearCart();
get().clearNotifications();
get().addNotification(`${user?.name ?? ''} has logged out`);
},
});// slices/cart.slice.ts
import { StateCreator } from 'zustand';
import { BoundStore, CartItem, CartSlice } from '../types';
export const createCartSlice: StateCreator<BoundStore, [], [], CartSlice> =
(set, get) => ({
items: [],
addItem: (item: CartItem) => {
set((state) => ({
items: [...state.items, item],
}));
get().addNotification(`${item.name} has been added to the cart`);
},
removeItem: (id: string) => {
set((state) => ({
items: state.items.filter((item) => item.id !== id),
}));
},
clearCart: () => set({ items: [] }),
// async signature since there's an actual API call
checkout: async () => {
const { user, items } = get();
if (!user) {
get().addNotification('Please log in to continue');
return;
}
if (items.length === 0) {
get().addNotification('Your cart is empty');
return;
}
// await paymentApi.checkout({ userId: user.id, items });
get().clearCart();
get().addNotification('Your order has been placed!');
},
});// slices/notification.slice.ts
import { StateCreator } from 'zustand';
import { BoundStore, NotificationSlice } from '../types';
export const createNotificationSlice: StateCreator<
BoundStore,
[],
[],
NotificationSlice
> = (set) => ({
notifications: [],
addNotification: (message: string) => {
set((state) => ({
notifications: [...state.notifications, message],
}));
},
clearNotifications: () => set({ notifications: [] }),
});Composing the store
// store.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { BoundStore } from './types';
import { createAuthSlice } from './slices/auth.slice';
import { createCartSlice } from './slices/cart.slice';
import { createNotificationSlice } from './slices/notification.slice';
export const useBoundStore = create<BoundStore>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createNotificationSlice(...a),
}),
{
name: 'app-store',
// Exclude sensitive user info from persist
partialize: (state) => ({
items: state.items, // Persist only the cart
}),
}
),
{ name: 'AppStore' }
)
);A note on using
persistmiddleware — If any initialization logic depends on the rehydration order of slices, unexpected issues can arise. For example, ifCartSlice's initialization logic referencesget().userbeforeAuthSlice'suserhas been restored. It's recommended to handle complex initialization logic inpersist'sonRehydrateStoragecallback.
Example 2: Blocking Unnecessary Re-renders with useShallow
useShallow, stabilized in v5, really shines when used with the slice pattern. When selecting state from multiple slices simultaneously — such as items from CartSlice and user from AuthSlice — it prevents unnecessary re-renders caused by referential equality issues.
import { useShallow } from 'zustand/react/shallow';
import { useBoundStore } from '../stores/store';
// ❌ The selector returns a new object every time, so it always re-renders
// (items from CartSlice, user from AuthSlice)
function CartBadge() {
const { items, user } = useBoundStore((state) => ({
items: state.items, // CartSlice
user: state.user, // AuthSlice
}));
// ...
}
// ✅ Wrapped with useShallow, re-renders only when items or user actually change
function CartBadge() {
const { items, user } = useBoundStore(
useShallow((state) => ({
items: state.items, // CartSlice
user: state.user, // AuthSlice
}))
);
return (
<div>
{user && <span>{user.name}</span>}
<span>Cart ({items.length})</span>
</div>
);
}The problem with the ❌ code has nothing to do with whether the store updated. Every time the selector is called, it returns a new object literal { items, user }, so when Zustand compares by referential equality (===), it always considers it a different value. Even a parent component re-rendering can trigger the same issue.
Example 3: Type Helpers for Combining Immer Middleware with Slices
This example is for those who have worked through the previous two. Using immer middleware with slices can sometimes break type inference. This is precisely where the TMutators argument mentioned in the core concepts needs [['zustand/immer', never], never].
To avoid repeating this long type every time, you can use a type helper pattern widely adopted in the community.
import { StateCreator } from 'zustand';
// Type helper for combining Immer + slices
type ImmerStateCreator<TStore, TSlice> = StateCreator<
TStore,
[['zustand/immer', never], never],
[],
TSlice
>;
// Usage example — reusable without lengthy type declarations
export const createCartSlice: ImmerStateCreator<BoundStore, CartSlice> =
(set, get) => ({
items: [],
addItem: (item) => {
set((state) => {
// Can mutate directly via Immer draft
state.items.push(item);
});
get().addNotification(`${item.name} has been added to the cart`);
},
// ...
});Pros and Cons
Pros
| Item | Details |
|---|---|
| Separation of concerns | State and actions are managed in independent files per domain, keeping file size and complexity under control |
| Type safety | The StateCreator generic catches type errors at compile time during cross-slice access |
| Centralized middleware | devtools, persist, etc. are applied only once to the composed store, avoiding duplicate configuration |
| Better collaboration | Splitting slice files per team member minimizes Git conflicts |
| Incremental adoption | Migration is possible by gradually moving pieces from an existing single store to slices |
Cons and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Type complexity | The pattern of passing the full store type in the first generic argument isn't intuitive at first | Declare the BoundStore type separately in types.ts and reference it consistently |
| Circular reference risk | Too many cross-references between slices can create dependency cycles | Maintain unidirectional dependencies; extract shared logic into utility functions |
| Immer type issues | Type inference can break when combining immer + slices |
Define an ImmerStateCreator type helper separately and reuse it |
| Test complexity | Testing cross-slice actions requires setting up the full store | Writing integration tests with the composed store is more reliable than unit-testing slice factories directly |
| Re-render management | Not using useShallow when selecting multiple pieces of state causes unnecessary re-renders |
Apply useShallow by default wherever multiple pieces of state are selected at once |
On the topic of test complexity — I also tried testing slices in isolation at first, but writing integration tests with the composed store turned out to be far more practical. With middleware stripped out and only the composition in place, you can test simply in either vitest or jest.
// store.test.ts
import { create } from 'zustand';
import { BoundStore } from './types';
import { createAuthSlice } from './slices/auth.slice';
import { createCartSlice } from './slices/cart.slice';
import { createNotificationSlice } from './slices/notification.slice';
const createTestStore = () =>
create<BoundStore>()((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createNotificationSlice(...a),
}));
test('logging out clears the cart', () => {
const useStore = createTestStore();
useStore.getState().login({ id: '1', name: 'Dev Kim', email: 'test@test.com' });
useStore.getState().addItem({ id: 'a', name: 'Product A', price: 1000, quantity: 1 });
useStore.getState().logout();
expect(useStore.getState().items).toHaveLength(0);
expect(useStore.getState().user).toBeNull();
});This approach creates a test store by composing only the slice factories, without any middleware. With no devtools or persist, you can verify state logic purely without any external dependencies.
The Most Common Mistakes in Practice
-
Applying middleware individually per slice — If you wrap
persistordevtoolsdirectly when definingcreateAuthSlice, each slice ends up with its own independent persist storage. Middleware must be applied exactly once during composition withcreate(). -
Putting only the slice's own type in the first generic argument of
StateCreator— This limits the return type ofget()to the current slice's type only, causing TypeScript errors when you try to call actions from other slices. You must putBoundStore(the full store type) here. -
Creating bidirectional cross-slice dependencies — A structure where
AuthSlicereferencesCartSliceandCartSlicealso referencesAuthSliceis the beginning of circular dependency warnings. Consider cleaning up the dependency direction so one side references the other unilaterally, or extracting shared logic into a separate slice likeNotificationSlice.
Closing Thoughts
Declaring the full store type in the first generic argument of StateCreator is the heart of type safety in the slice pattern — understand this one principle and everything else follows naturally.
Three steps you can start with right now:
-
Create a
types.tsfile first — Start by extracting types from your existing store, separating them intoAuthSlice,CartSlice, etc., and defining theBoundStoretype that combines them. Moving code comes in the next step. -
Separate slice files one by one — Move the contents of your existing
create()into factory functions shaped asStateCreator<BoundStore, [], [], SliceType>, then recombine them with spread operators instore.ts. The behavior stays exactly the same. -
Apply
get()wherever you have cross-slice actions — For actions that span multiple domains — like logout handling — try refactoring to theget().clearCart()form and verify that TypeScript catches any type errors along the way.
References
- Slices Pattern | Zustand Official Docs
- Slices Pattern | DeepWiki (pmndrs/zustand)
- zustandjs/zustand-slices | GitHub
- zustandjs/zustand-slices | DeepWiki
- ADVANCED ZUSTAND "4", Slices Pattern & Scalable Store Architecture | Level Up Coding
- A Slice-Based Zustand Store for Next.js 14 and TypeScript | Atlys Engineering
- Zustand Architecture Patterns at Scale | Brainhub
- When to use multiple stores vs slices | GitHub Discussions
- combine | Zustand Official Middleware Docs