Build a Production-Ready Zustand + Immer State Management Library
State management in React can be complex, but it doesn't have to be. In this article, I'll show you how to build a production-ready, type-safe, and highly reusable state management library combining Zustand and Immer.
Why Zustand + Immer?
Zustand is a lightweight state management solution that's:
- πͺΆ Minimal boilerplate
- β‘ Fast and efficient
- π― No providers needed
- π§ Easy to learn
Immer adds:
- βοΈ Mutable syntax for immutable updates
- π‘οΈ Safe nested state updates
- π More readable code
Together, they create a powerful yet simple state management solution.
What We'll Build
A complete library with:
- β Type-safe store creation
- β Immer integration by default
- β Optional persistence (localStorage)
- β Redux DevTools integration
- β Modular store slices
- β Optimized selectors
- β Reset utilities
- β Development logger
Installation
npm install zustand immer
Core Library
1. Store Creator
This is the heart of our library - a function that creates stores with all features enabled:
// lib/zustand/createStore.ts
import { create, StateCreator } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface CreateStoreConfig {
/** Store name for DevTools */
name: string;
/** Enable persistence to localStorage */
persist?: boolean;
/** Keys to persist (if undefined, persists all state) */
persistKeys?: string[];
/** Enable Redux DevTools integration */
devtools?: boolean;
/** Enable fine-grained subscriptions */
enableSelectors?: boolean;
}
export const createStore = <T extends object>(config: CreateStoreConfig) => {
const {
name,
persist: enablePersist = false,
persistKeys,
devtools: enableDevtools = process.env.NODE_ENV === 'development',
enableSelectors = true,
} = config;
return (stateCreator: StateCreator<T, [['zustand/immer', never]], []>) => {
let store = immer<T>(stateCreator);
if (enableSelectors) {
store = subscribeWithSelector(store) as any;
}
if (enablePersist) {
store = persist(store, {
name: `${name}-storage`,
...(persistKeys && {
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => persistKeys.includes(key))
) as T,
}),
}) as any;
}
if (enableDevtools) {
store = devtools(store, { name }) as any;
}
return create<T>()(store);
};
};
2. Optimized Selectors
Create selector hooks that prevent unnecessary re-renders:
// lib/zustand/createSelectors.ts
export const createSelectors = <T extends object>(store: any) => {
const selectors = {} as {
[K in keyof T]: () => T[K];
};
const stateKeys = Object.keys(store.getState()) as (keyof T)[];
stateKeys.forEach((key) => {
(selectors as any)[key] = () => store((state: T) => state[key]);
});
return selectors;
};
3. Slice Creator
Type helper for creating modular store slices:
// lib/zustand/createSlice.ts
import { StateCreator } from 'zustand';
export type CreateSlice<T> = StateCreator<
T,
[['zustand/immer', never]],
[],
T
>;
4. Combine Slices
Merge multiple slices into one store:
// lib/zustand/combineSlices.ts
export const combineSlices = <T extends object>(
...slices: Array<(set: any, get: any, api: any) => Partial<T>>
) => {
return (set: any, get: any, api: any) => {
return slices.reduce(
(acc, slice) => ({
...acc,
...slice(set, get, api),
}),
{} as T
);
};
};
5. Reset Utilities
Built-in reset functionality:
// lib/zustand/resetters.ts
export const createResetters = <T extends object>(
initialState: T,
set: (fn: (state: T) => void) => void
) => ({
reset: () =>
set((state: any) => {
Object.assign(state, initialState);
}),
resetKey: <K extends keyof T>(params: { key: K }) =>
set((state: any) => {
state[params.key] = initialState[params.key];
}),
});
6. Development Logger
Log state changes in development:
// lib/zustand/logger.ts
export const createLogger = (storeName: string) => {
if (process.env.NODE_ENV !== 'development') {
return () => {};
}
return (state: any, prevState: any) => {
console.group(`[${storeName}] State Changed`);
console.log('Previous:', prevState);
console.log('Current:', state);
console.groupEnd();
};
};
7. Main Export
// lib/zustand/index.ts
export { createStore } from './createStore';
export { createSelectors } from './createSelectors';
export { createSlice, type CreateSlice } from './createSlice';
export { combineSlices } from './combineSlices';
export { createResetters } from './resetters';
export { createLogger } from './logger';
Usage Examples
Simple Counter Store
// stores/useCounterStore.ts
import { createStore } from '@/lib/zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const initialState = {
count: 0,
};
export const useCounterStore = createStore<CounterState>({
name: 'counter',
persist: true,
})((set) => ({
...initialState,
increment: () =>
set((state) => {
state.count += 1; // Mutable syntax thanks to Immer!
}),
decrement: () =>
set((state) => {
state.count -= 1;
}),
reset: () =>
set((state) => {
state.count = initialState.count;
}),
}));
In Your Component:
import { useCounterStore } from '@/stores/useCounterStore';
export const Counter = () => {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
};
Modular Store with Slices
Create reusable slices:
// stores/slices/userSlice.ts
import { type CreateSlice } from '@/lib/zustand';
interface User {
id: string;
name: string;
email: string;
}
export interface UserSlice {
user: User | null;
setUser: (params: { user: User | null }) => void;
clearUser: () => void;
}
export const createUserSlice: CreateSlice<UserSlice> = (set) => ({
user: null,
setUser: ({ user }) =>
set((state) => {
state.user = user;
}),
clearUser: () =>
set((state) => {
state.user = null;
}),
});
// stores/slices/settingsSlice.ts
import { type CreateSlice } from '@/lib/zustand';
export interface SettingsSlice {
theme: 'light' | 'dark';
language: string;
setTheme: (params: { theme: 'light' | 'dark' }) => void;
setLanguage: (params: { language: string }) => void;
}
export const createSettingsSlice: CreateSlice<SettingsSlice> = (set) => ({
theme: 'light',
language: 'en',
setTheme: ({ theme }) =>
set((state) => {
state.theme = theme;
}),
setLanguage: ({ language }) =>
set((state) => {
state.language = language;
}),
});
Combine them:
// stores/useAppStore.ts
import { createStore, combineSlices } from '@/lib/zustand';
import { createUserSlice, UserSlice } from './slices/userSlice';
import { createSettingsSlice, SettingsSlice } from './slices/settingsSlice';
type AppStore = UserSlice & SettingsSlice;
export const useAppStore = createStore<AppStore>({
name: 'app',
persist: true,
persistKeys: ['user', 'theme', 'language'], // Only persist these
devtools: true,
})(combineSlices(createUserSlice, createSettingsSlice));
Optimized Selectors
Prevent unnecessary re-renders:
// stores/useProductStore.ts
import { createStore, createSelectors } from '@/lib/zustand';
interface Product {
id: string;
name: string;
price: number;
}
interface ProductState {
products: Product[];
loading: boolean;
addProduct: (params: { product: Product }) => void;
setLoading: (params: { loading: boolean }) => void;
}
const useProductStoreBase = createStore<ProductState>({
name: 'products',
persist: true,
})((set) => ({
products: [],
loading: false,
addProduct: ({ product }) =>
set((state) => {
state.products.push(product);
}),
setLoading: ({ loading }) =>
set((state) => {
state.loading = loading;
}),
}));
// Create optimized selectors
export const useProductStore = createSelectors(useProductStoreBase);
In Your Component:
import { useProductStore } from '@/stores/useProductStore';
export const ProductList = () => {
// Only re-renders when products change, not when loading changes
const products = useProductStore.products();
const addProduct = useProductStore.addProduct();
return (
<div>
{products.map((p) => (
<div key={p.id}>{p.name} - ${p.price}</div>
))}
<button onClick={() => addProduct({
product: { id: '1', name: 'New Product', price: 99 }
})}>
Add Product
</button>
</div>
);
};
Store with Reset Utilities
// stores/useFormStore.ts
import { createStore, createResetters } from '@/lib/zustand';
interface FormState {
name: string;
email: string;
message: string;
setName: (params: { name: string }) => void;
setEmail: (params: { email: string }) => void;
setMessage: (params: { message: string }) => void;
reset: () => void;
resetKey: <K extends keyof Pick<FormState, 'name' | 'email' | 'message'>>(
params: { key: K }
) => void;
}
const initialState = {
name: '',
email: '',
message: '',
};
export const useFormStore = createStore<FormState>({
name: 'form',
})((set) => ({
...initialState,
setName: ({ name }) => set((state) => { state.name = name }),
setEmail: ({ email }) => set((state) => { state.email = email }),
setMessage: ({ message }) => set((state) => { state.message = message }),
...createResetters(initialState, set),
}));
Usage:
const { name, email, setName, setEmail, reset, resetKey } = useFormStore();
// Reset entire form
reset();
// Reset only email field
resetKey({ key: 'email' });
Development Logger
import { createLogger } from '@/lib/zustand';
import { useDebugStore } from '@/stores/useDebugStore';
// Subscribe to all state changes (development only)
useDebugStore.subscribe(createLogger('DebugStore'));
Best Practices
1. Always Use Object Parameters
// β
Good - Easy to remember, order doesn't matter
setUser: ({ user }) => set((state) => { state.user = user })
// β Bad - Hard to remember parameter order
setUser: (user) => set((state) => { state.user = user })
2. Type Everything
// β
Good - Full type safety
interface UserState {
user: User | null;
setUser: (params: { user: User | null }) => void;
}
// β Bad - No type safety
const useUserStore = create((set: any) => ({
user: null,
setUser: (user: any) => set({ user }),
}));
3. Split Large Stores into Slices
// β
Good - Modular and reusable
const useAppStore = createStore()(
combineSlices(
createUserSlice,
createProductSlice,
createCartSlice,
createOrderSlice
)
);
// β Bad - One giant store
const useAppStore = createStore()((set) => ({
// 500 lines of state and actions...
}));
4. Use Selectors for Performance
// β
Good - Only re-renders when products change
const products = useProductStore.products();
// β Bad - Re-renders on any store change
const { products } = useProductStore();
5. Only Persist What's Necessary
// β
Good - Only persist user preferences
createStore({
name: 'app',
persist: true,
persistKeys: ['theme', 'language', 'user'],
})
// β Bad - Persisting temporary state
createStore({
name: 'app',
persist: true, // Persists loading states, errors, etc.
})
Performance Tips
- Use Selectors: Only subscribe to the state you need
- Split Stores: Separate concerns (auth, UI, data)
- Lazy Initialize: Don't compute expensive initial state
- Batch Updates: Use Immer to batch multiple state changes
- Memoize Computed Values: Use useMemo for derived state
Advanced: Async Actions
interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
fetchTodos: () => Promise<void>;
}
export const useTodoStore = createStore<TodoState>({
name: 'todos',
persist: true,
persistKeys: ['todos'],
})((set) => ({
todos: [],
loading: false,
error: null,
fetchTodos: async () => {
set((state) => {
state.loading = true;
state.error = null;
});
try {
const response = await fetch('/api/todos');
const todos = await response.json();
set((state) => {
state.todos = todos;
state.loading = false;
});
} catch (error) {
set((state) => {
state.error = error.message;
state.loading = false;
});
}
},
}));
Testing
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from './useCounterStore';
describe('useCounterStore', () => {
beforeEach(() => {
useCounterStore.getState().reset();
});
it('increments count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('persists to localStorage', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
const stored = JSON.parse(
localStorage.getItem('counter-storage') || '{}'
);
expect(stored.state.count).toBe(1);
});
});
Summary
This library gives you:
β
Type-Safe - Full TypeScript support
β
Performant - Optimized selectors prevent re-renders
β
Modular - Split stores into reusable slices
β
Persistent - Optional localStorage with key selection
β
Debuggable - Redux DevTools integration
β
Clean - Mutable syntax via Immer
β
Production-Ready - Battle-tested patterns
Complete Example
Here's everything together:
// stores/useShopStore.ts
import {
createStore,
combineSlices,
createSelectors,
type CreateSlice
} from '@/lib/zustand';
// User Slice
interface User {
id: string;
name: string;
}
interface UserSlice {
user: User | null;
setUser: (params: { user: User | null }) => void;
}
const createUserSlice: CreateSlice<UserSlice> = (set) => ({
user: null,
setUser: ({ user }) => set((state) => { state.user = user }),
});
// Cart Slice
interface CartItem {
id: string;
quantity: number;
}
interface CartSlice {
items: CartItem[];
addItem: (params: { item: CartItem }) => void;
removeItem: (params: { itemId: string }) => void;
}
const createCartSlice: CreateSlice<CartSlice> = (set) => ({
items: [],
addItem: ({ item }) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
state.items.push(item);
}
}),
removeItem: ({ itemId }) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== itemId);
}),
});
// Combine and export
type ShopStore = UserSlice & CartSlice;
const useShopStoreBase = createStore<ShopStore>({
name: 'shop',
persist: true,
persistKeys: ['user', 'items'],
devtools: true,
})(combineSlices(createUserSlice, createCartSlice));
export const useShopStore = createSelectors(useShopStoreBase);
// components/Cart.tsx
import { useShopStore } from '@/stores/useShopStore';
export const Cart = () => {
const items = useShopStore.items();
const addItem = useShopStore.addItem();
const removeItem = useShopStore.removeItem();
return (
<div>
<h2>Cart ({items.length})</h2>
{items.map((item) => (
<div key={item.id}>
Quantity: {item.quantity}
<button onClick={() => removeItem({ itemId: item.id })}>
Remove
</button>
</div>
))}
</div>
);
};
Conclusion
You now have a complete, production-ready state management library that combines the simplicity of Zustand with the power of Immer. This setup has:
- Minimal boilerplate
- Maximum type safety
- Excellent performance
- Great developer experience
Try it in your next project and let me know how it works for you!
Resources
What's your favorite state management solution? Let me know in the comments! π
If you found this helpful, please give it a β€οΈ and share it with your team!
Top comments (0)