DEV Community

Cover image for πŸ”₯ Stop Writing Boilerplate: Zustand + Immer, Done Once and Right
abdelrahman seada
abdelrahman seada

Posted on

πŸ”₯ Stop Writing Boilerplate: Zustand + Immer, Done Once and Right

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
Enter fullscreen mode Exit fullscreen mode

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);
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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
>;
Enter fullscreen mode Exit fullscreen mode

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
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

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];
    }),
});
Enter fullscreen mode Exit fullscreen mode

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();
  };
};
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
    }),
}));
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
    }),
});
Enter fullscreen mode Exit fullscreen mode
// 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;
    }),
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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),
}));
Enter fullscreen mode Exit fullscreen mode

Usage:

const { name, email, setName, setEmail, reset, resetKey } = useFormStore();

// Reset entire form
reset();

// Reset only email field
resetKey({ key: 'email' });
Enter fullscreen mode Exit fullscreen mode

Development Logger

import { createLogger } from '@/lib/zustand';
import { useDebugStore } from '@/stores/useDebugStore';

// Subscribe to all state changes (development only)
useDebugStore.subscribe(createLogger('DebugStore'));
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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 }),
}));
Enter fullscreen mode Exit fullscreen mode

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...
}));
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.
})
Enter fullscreen mode Exit fullscreen mode

Performance Tips

  1. Use Selectors: Only subscribe to the state you need
  2. Split Stores: Separate concerns (auth, UI, data)
  3. Lazy Initialize: Don't compute expensive initial state
  4. Batch Updates: Use Immer to batch multiple state changes
  5. 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;
      });
    }
  },
}));
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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)