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
createfunction 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 }),
}));
createtakes a function that defines the initial state and actions.setis a function to update the state, receiving the current state and returning the new state.- TypeScript’s
interfaceensures 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
setto 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
useStoreto 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
storageoption accounts for Next.js server-side rendering (SSR), ensuring it only accesseslocalStoragein 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/extensionfor 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
createinfers 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
persistchecks forwindowto 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 usesetwith a new object. - SSR Issues: Ensure browser-specific APIs (e.g.,
localStorage) are guarded withtypeof window !== 'undefined'. - Over-Rendering: Use selectors to limit re-renders to specific state changes.
Comments