Zustand is a lightweight, simple, and scalable state management library for React applications. It allows you to manage global state without the boilerplate of Redux, using a minimal API based on hooks. It’s particularly well-suited for Next.js projects due to its simplicity and compatibility with React’s ecosystem.

2. Key Features

  • Minimal API: Uses a single create function to define a store.
  • Hook-based: Access state and update functions via hooks like useStore.
  • No Provider Needed: Unlike Context or Redux, Zustand doesn’t require a provider, simplifying setup in Next.js.
  • TypeScript Support: Strong typing out of the box, making it ideal for TypeScript projects.
  • Middleware: Supports middleware for persistence, logging, immutability, etc.
  • Small Bundle Size: Lightweight (~1KB), great for performance in Next.js apps.

3. Installation in Your Project

Since you’re using PNPM, install Zustand with:

pnpm add zustand

This adds Zustand to your package.json and works seamlessly with Next.js and TypeScript.

4. Basic Usage

Here’s how to set up and use a Zustand store in your Next.js + TypeScript project:

a. Creating a Store

Define your store with TypeScript types for safety and clarity:

import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));
  • create takes a function that defines the initial state and actions.
  • set is a function to update the state, receiving the current state and returning the new state.
  • TypeScript’s interface ensures type safety for the state and actions.

b. Using the Store in Components

Access the store in your Next.js components with the useStore hook:

'use client'; // Required for Next.js client components

import { useCounterStore } from './store';

export default function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  const reset = useCounterStore((state) => state.reset);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}
  • Note the 'use client' directive, as Zustand relies on React hooks, which are client-side in Next.js.
  • The hook takes a selector function to pick specific state pieces, optimizing re-renders.

5. Key Concepts

  • State Updates: Use set to update state. It’s immutable by convention—return a new object instead of mutating the existing one.

    set((state) => ({ count: state.count + 1 }));
    
  • Selectors: Pass a function to useStore to select only the needed state slice (e.g., state.count), improving performance by avoiding unnecessary re-renders.
  • No Context Provider: Unlike React Context, Zustand stores are globally accessible without wrapping your app in a provider, perfect for Next.js’s component-based structure.

6. Middleware

Zustand supports middleware to extend functionality. Common ones include:

a. Persist Middleware

Persists state to storage (e.g., localStorage) for persistence across page reloads, useful for Next.js apps:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CounterState {
  count: number;
  increment: () => void;
}

const useCounterStore = create<CounterState>()(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    {
      name: 'counter-storage', // Key for localStorage
      storage: typeof window !== 'undefined' ? localStorage : undefined, // Check for browser env in Next.js
    }
  )
);
  • The storage option accounts for Next.js server-side rendering (SSR), ensuring it only accesses localStorage in the browser.

b. Devtools Middleware

Integrates with Redux DevTools for debugging:

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    { name: 'CounterStore' }
  )
);
  • Install pnpm add @redux-devtools/extension for TypeScript types if needed.

7. TypeScript Best Practices

  • Define Interfaces: Always use TypeScript interfaces or types for your store to ensure type safety.
  • Combine Types: For complex stores, combine multiple interfaces:

    interface UserState {
      name: string;
      setName: (name: string) => void;
    }
    
    interface CounterState {
      count: number;
      increment: () => void;
    }
    
    const useStore = create<UserState & CounterState>((set) => ({
      name: 'User',
      setName: (name) => set({ name }),
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }));
    
  • Type Inference: Zustand’s create infers types from the interface, reducing boilerplate.

8. Next.js Considerations

  • Client-Side Only: Zustand uses hooks, so components accessing the store must be client components ('use client' in Next.js 13+ App Router).
  • SSR Compatibility: Initial state renders on the server, but updates happen client-side. For persisted state, ensure middleware like persist checks for window to avoid SSR errors.
  • Performance: Use selectors (e.g., useCounterStore((state) => state.count)) to prevent re-renders when unrelated state changes.

9. Advanced Usage

  • Async Actions: Handle async operations (e.g., API calls) in actions:

    interface UserState {
      user: string | null;
      loading: boolean;
      fetchUser: () => Promise<void>;
    }
    
    const useUserStore = create<UserState>((set) => ({
      user: null,
      loading: false,
      fetchUser: async () => {
        set({ loading: true });
        const response = await fetch('/api/user');
        const data = await response.json();
        set({ user: data.name, loading: false });
      },
    }));
    
  • Computed State: Derive values without storing them:

    interface Store {
      count: number;
      double: number;
      increment: () => void;
    }
    
    const useStore = create<Store>((set) => ({
      count: 0,
      double: 0, // Not actually stored, computed below
      increment: () => set((state) => ({ count: state.count + 1 })),
      get double() {
        return this.count * 2;
      },
    }));
    

    Use it like: const double = useStore((state) => state.double).

10. Benefits for Your Project

  • Next.js: Works seamlessly with client components, ideal for dynamic, interactive UIs.
  • TypeScript: Strong typing ensures robust, error-free code.
  • PNPM: Lightweight dependency management aligns with Zustand’s small size and fast install via PNPM.
  • Scalability: Simple for small apps, yet scales with middleware for complex Next.js projects.

11. Common Pitfalls

  • Mutating State: Avoid directly mutating state (e.g., state.count++). Always use set with a new object.
  • SSR Issues: Ensure browser-specific APIs (e.g., localStorage) are guarded with typeof window !== 'undefined'.
  • Over-Rendering: Use selectors to limit re-renders to specific state changes.

Comments