Using Zustand State Slicing to Keep Global Stores Maintainable
Learn how to split a Zustand store into slices to keep global state maintainable, with a concrete code example, limits, and verification steps.
26 Mar 2026, 08:29 UTC

Why slice a Zustand store?
When a single Zustand store grows large, updating and testing it becomes cumbersome. State slicing lets you split the store into independent functions (slices) that each own a piece of state, then merge them back into a single root store. This gives you a single source of truth while keeping logic isolated.
Worked example: two slices merged into one root store
Assume you need a UI that tracks both a user profile and a list of notifications. Each concern lives in its own slice.
import { create } from 'zustand';
// ---- Slice A: user profile ----
const useUserSlice = (set, get, store) => ({
user: { id: null, name: '' },
setUser: (user) => set({ user }),
// example of reading from another slice (see slice B)
greetUser: () => {
const { notifications } = get();
if (notifications.length > 0) {
alert(`Hello ${get().user.name}, you have ${notifications.length} new notifications`);
}
},
});
// ---- Slice B: notifications ----
const useNotificationsSlice = (set, get, store) => ({
notifications: [],
addNotification: (msg) => set((state) => ({
notifications: [...state.notifications, msg]
})),
clearNotifications: () => set({ notifications: [] }),
});
// ---- Root store ----
const useStore = create((...args) => ({
...useUserSlice(...args),
...useNotificationsSlice(...args),
}));
export default useStore;
Each slice receives the standard set, get, and store arguments. The root store is created by spreading the results of both slice functions into the initializer passed to create. The resulting hook useStore exposes a unified API:
useStore((s) => s.user)gives the user object.useStore((s) => s.notifications)gives the notifications array.- Cross‑slice access works via
get()inside any slice, as shown ingreetUser.
Limits and common mistakes
Circular dependencies
If Slice A calls a function in Slice B that, in turn, calls back into Slice A during the same synchronous update, you can trigger an infinite loop or a runtime error. To avoid this, keep slice interactions read‑only where possible, or defer writes using setTimeout or queueMicrotask.
Over‑slicing
Dividing state into too many tiny slices can make it hard to follow where a particular piece of state is mutated. A good rule of thumb is to slice by domain concern (e.g., authentication, UI theme, data cache) rather than by individual fields.
TypeScript typing
When using TypeScript, explicitly type the slice return shape. Forgetting to do so falls back to any and loses autocomplete:
type UserSlice = {
user: { id: number | null; name: string };
setUser: (user: { id: number | null; name: string }) => void;
greetUser: () => void;
};
const useUserSlice = (set, get, store): UserSlice => ({
/* implementation */
});
Verify your slices by checking that the root store’s TypeScript inference matches the combined shape.
Practical verification steps
- Render a component that calls
useStore((s) => s.setUser({ id: 1, name: 'Ada' }))and another that readsuseStore((s) => s.user); confirm the UI updates. - From a button in the notifications slice, dispatch
greetUserand ensure it reads the latest notification count viaget(). - Open Redux DevTools (if you have the middleware installed) and verify that the state tree shows a single object with
userandnotificationstop‑level keys.
When to avoid slicing
If your store is tiny (under three related values) or if slices would constantly need to read each other's state, a single slice may be clearer. Introduce slicing only when the store’s logic starts to feel tangled or when multiple teams own different parts of the state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.