W
Web Dev Simplified
#Zustand#React#State Management

Zustand State Management: A Comprehensive Guide for React Developers

Learn Zustand, a powerful state management library for React, to build scalable applications. This guide covers setup, performance optimization with selectors and middleware like Immer and Persist, and best practices for managing complex state efficiently.

5 min readAI Guide

Introduction

Introduction
Zustand is a small, fast, and scalable state management solution that simplifies global state management in React applications. It allows developers to build applications ranging from simple counters to complex in-browser card games with high performance and maintainability.

Configuration Checklist

Element Version / Link
Language / Runtime TypeScript, JavaScript / Node.js
Main library Zustand (npm install zustand)
Required APIs React Hooks (useState, useContext)
Keys / credentials needed None (for basic setup)

Step-by-Step Guide

Step 1 — Setting Up the Initial React Context Counter

To understand Zustand's benefits, we first set up a basic counter application using React's Context API. This demonstrates the common re-rendering issues that Zustand aims to solve.

src/state/CounterContext.tsx

import { createContext, useState, useContext, ReactNode } from 'react';

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

// Create the context with a default null value, asserting its type
const CounterContext = createContext<CounterState | null>(null);

// CounterProvider component to wrap the application and provide state
export function CounterProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);

  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset = () => setCount(0);

  // Provide the count and functions to update it
  return (
    <CounterContext.Provider value={{ count, increment, decrement, reset }}>
      {children}
    </CounterContext.Provider>
  );
}

// Custom hook to consume the counter context
export function useCounter() {
  const value = useContext(CounterContext);
  if (value === null) {
    throw new Error('useCounter must be wrapped by CounterProvider');
  }
  return value;
}

src/components/Counter.tsx

import { useRef } from 'react';
import { useFlash } from '../hooks/flash'; // Custom hook to visually indicate re-renders
import { CounterProvider, useCounter } from '../state/CounterContext';

// Main Counter component that wraps the display and controls with the provider
export function Counter() {
  return (
    <CounterProvider>
      <CountDisplay />
      <CountControls />
    </CounterProvider>
  );
}

// Component to display the current count and re-render count
function CountDisplay() {
  const { count } = useCounter(); // Consumes the count from context
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef); // Visually flashes on re-render

  return (
    <div className="card" ref={cardRef}>
      <p className="count-display">{count}</p>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

// Component with controls to modify the count
function CountControls() {
  const { increment, decrement, reset } = useCounter(); // Consumes functions from context
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef); // Visually flashes on re-render

  // Function to increment count outside React component tree (for demonstration)
  function incrementOutsideReact(incrementFn: () => void) {
    console.log('Outside of React');
    incrementFn();
  }

  return (
    <div className="card" ref={cardRef}>
      <div className="button-group">
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
        <button onClick={increment}>+1</button>
        <button onClick={() => incrementOutsideReact(increment)}>+1 Outside</button>
      </div>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

Step 2 — Installing Zustand and Creating a Basic Store

Zustand is installed via npm. A store is created using the create function, which returns a custom hook. This hook provides access to the store's state and a set function to update it.

Install Command:

npm install zustand

src/state/useCounterStore.ts

import { create } from "zustand";

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

// Create the Zustand store, typed with CounterState
export const useCounterStore = create<CounterState>((set) => ({
  count: 0, // Initial state
  // Actions to update the state using the 'set' function
  increment: () => set((state) => ({ count: state.count + 1 })), // Zustand automatically merges partial state updates
  decrement: () => set((state) => ({ count: state.count - 1 })), // Decrement count
  reset: () => set({ count: 0 }), // Reset count to 0
}));

Step 3 — Replacing React Context with Zustand in Components

We replace the CounterProvider and useCounter hook with the useCounterStore hook. Initially, both components will still re-render excessively because the entire store object is returned on each call.

src/components/Counter.tsx

import { useRef } from 'react';
import { useFlash } from '../hooks/flash';
import { useCounterStore } from '../state/useCounterStore'; // Import the Zustand store hook

// Main Counter component, now without CounterProvider
export function Counter() {
  return (
    <>
      <CountDisplay />
      <CountControls />
    </>
  );
}

// Component to display the current count and re-render count
function CountDisplay() {
  // Directly use the Zustand store hook
  const { count } = useCounterStore(); // This will cause re-renders if any part of the store changes
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef);

  return (
    <div className="card" ref={cardRef}>
      <p className="count-display">{count}</p>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

// Component with controls to modify the count
function CountControls() {
  // Directly use the Zustand store hook
  const { increment, decrement, reset } = useCounterStore(); // This will also cause re-renders
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef);

  // Function to increment count outside React component tree
  function incrementOutsideReact(incrementFn: () => void) {
    console.log('Outside of React');
    incrementFn();
  }

  return (
    <div className="card" ref={cardRef}>
      <div className="button-group">
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
        <button onClick={increment}>+1</button>
        <button onClick={() => incrementOutsideReact(increment)}>+1 Outside</button>
      </div>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

Step 4 — Optimizing Re-renders with Selectors and useShallow

To prevent unnecessary re-renders, Zustand allows you to select only the specific parts of the state your component needs. For multiple selections, useShallow ensures that the component only re-renders if the selected values actually change, not just their object reference.

Install Command (for shallow):

npm install zustand

src/components/Counter.tsx

import { useRef } from 'react';
import { useFlash } from '../hooks/flash';
import { useCounterStore } from '../state/useCounterStore';
import { shallow } from 'zustand/shallow'; // Import shallow for optimized object comparison

export function Counter() {
  return (
    <>
      <CountDisplay />
      <CountControls />
    </>
  );
}

function CountDisplay() {
  // Select only the 'count' value, so this component only re-renders when 'count' changes
  const count = useCounterStore((state) => state.count);
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef);

  return (
    <div className="card" ref={cardRef}>
      <p className="count-display">{count}</p>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

function CountControls() {
  // Select multiple functions and use 'shallow' to prevent re-renders if functions themselves don't change
  const { increment, decrement, reset } = useCounterStore(
    (state) => ({
      increment: state.increment,
      decrement: state.decrement,
      reset: state.reset,
    }),
    shallow // Use shallow comparison for the returned object
  );
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef);

  function incrementOutsideReact(incrementFn: () => void) {
    console.log('Outside of React');
    incrementFn();
  }

  return (
    <div className="card" ref={cardRef}>
      <div className="button-group">
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
        <button onClick={increment}>+1</button>
        <button onClick={() => incrementOutsideReact(increment)}>+1 Outside</button>
      </div>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

Step 5 — Accessing State Outside React Components

Zustand stores can be accessed and updated outside of React components, which is useful for non-React logic or integrating with other frameworks. The store itself provides getState() and setState() methods.

src/state/useCounterStore.ts

import { create } from "zustand";

type CounterState = {
  count: number;
};

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
}));

// Export actions as standalone functions for access outside React
export const increment = () => useCounterStore.setState((state) => ({ count: state.count + 1 }));
export const decrement = () => useCounterStore.setState((state) => ({ count: state.count - 1 }));
export const reset = () => useCounterStore.setState({ count: 0 });

src/components/Counter.tsx

import { useRef } from 'react';
import { useFlash } from '../hooks/flash';
import { useCounterStore, increment, decrement, reset } from '../state/useCounterStore'; // Import actions directly
import { shallow } from 'zustand/shallow';

export function Counter() {
  return (
    <>
      <CountDisplay />
      <CountControls />
    </>
  );
}

function CountDisplay() {
  const count = useCounterStore((state) => state.count);
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef);

  return (
    <div className="card" ref={cardRef}>
      <p className="count-display">{count}</p>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

function CountControls() {
  // Actions are now imported directly, no need to select them from the store hook
  const cardRef = useRef<HTMLDivElement>(null);
  const renders = useFlash(cardRef);

  // Function to increment count outside React component tree
  function incrementOutsideReact() {
    console.log('Outside of React');
    // Directly call the exported increment function
    increment();
  }

  return (
    <div className="card" ref={cardRef}>
      <div className="button-group">
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
        <button onClick={increment}>+1</button>
        <button onClick={incrementOutsideReact}>+1 Outside</button>
      </div>
      <p className="render-count">Renders: {renders}</p>
    </div>
  );
}

Step 6 — Persisting State with persist Middleware

Zustand offers middleware to extend store functionality. The persist middleware allows you to save and load your store's state from storage (like local storage) automatically.

Install Command:

npm install zustand

src/state/useCounterStore.ts

import { create } from "zustand";
import { persist } from "zustand/middleware"; // Import persist middleware

type CounterState = {
  count: number;
};

// Wrap the store creation with persist middleware
export const useCounterStore = create<CounterState>()(
  persist(
    (set) => ({
      count: 0,
    }),
    {
      name: "count", // Name for the storage key (e.g., in local storage)
    }
  )
);

export const increment = () => useCounterStore.setState((state) => ({ count: state.count + 1 }));
export const decrement = () => useCounterStore.setState((state) => ({ count: state.count - 1 }));
export const reset = () => useCounterStore.setState({ count: 0 });

Step 7 — Managing Nested State with Immer Middleware

For deeply nested state, manual immutable updates can become verbose. The immer middleware simplifies this by allowing you to write mutable-looking code that Immer then translates into immutable updates.

Install Command:

npm install immer

src/state/useCounterStore.ts

import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer"; // Import immer middleware

type User = {
  name: string;
  address: {
    street: string;
    zipcode: string;
  };
};

type CounterState = {
  count: number;
  user: User; // Add a nested user object to the state
};

export const useCounterStore = create<CounterState>()(
  immer( // Wrap with immer middleware
    persist(
      (set) => ({
        count: 0,
        user: {
          name: "Kyle",
          address: {
            street: "Main St",
            zipcode: "23423",
          },
        }, // Initial nested user state
      }),
      {
        name: "count",
      }
    )
  )
);

export const increment = () => useCounterStore.setState((state) => { state.count++; }); // Direct mutation with immer
export const decrement = () => useCounterStore.setState((state) => { state.count--; }); // Direct mutation with immer
export const reset = () => useCounterStore.setState((state) => { state.count = 0; }); // Direct mutation with immer

// Function to update a nested property using immer
export const updateStreet = (newStreet: string) =>
  useCounterStore.setState((state) => {
    state.user.address.street = newStreet; // Direct mutation of nested state is safe with immer
  });

Comparison Tables

Feature / Tool React Context API Zustand
Ease of Setup Relatively easy for simple cases Easy, similar to Context but more flexible
Performance (Re-renders) All consuming components re-render on any context change, leading to potential performance issues Optimized re-renders with selectors; only components consuming changed state re-render
Scalability Can become difficult to scale with large, complex state trees due to re-render issues Highly scalable, designed for large applications with fine-grained control over re-renders
Access Outside React Requires passing functions/state as props or through custom hooks within the React tree Direct access to getState() and setState() methods anywhere, independent of React components
Nested State Management Requires manual immutable updates (e.g., spreading objects) which can be verbose Simplified with immer middleware, allowing direct mutation of draft state

⚠️ Common Mistakes & Pitfalls

  1. Excessive Re-renders with React Context: When any value in a React Context changes, all components consuming that context will re-render, even if they don't use the specific changed value. This can lead to performance bottlenecks in larger applications.
    • Fix: Use a state management library like Zustand with selectors to ensure components only re-render when their specific dependencies change.
  2. Excessive Re-renders with Zustand (without selectors): If you call useCounterStore() without a selector (e.g., const { count } = useCounterStore();), your component will receive the entire store object. If any part of the store changes, React will see a new object reference and re-render the component.
    • Fix: Always use selectors to extract only the necessary state: const count = useCounterStore(state => state.count);.
  3. Excessive Re-renders with Zustand (object selectors without shallow): When selecting multiple values or an object from the store (e.g., const { a, b } = useCounterStore(state => ({ a: state.a, b: state.b }));), a new object reference is created on each render. React's default strict equality check will see this as a change, causing unnecessary re-renders.
    • Fix: Pass shallow as the second argument to the useStore hook for object comparisons: const { a, b } = useCounterStore(state => ({ a: state.a, b: state.b }), shallow);.
  4. Complex Immutable Updates for Nested State: Manually updating deeply nested state objects requires careful spreading of properties ({ ...state, user: { ...state.user, address: { ...state.user.address, street: 'New St' } } }), which is verbose and error-prone.
    • Fix: Use the immer middleware with Zustand. It allows you to write mutable-looking code (e.g., state.user.address.street = 'New St';) that is automatically converted into immutable updates, simplifying complex state logic.

Glossary

Zustand: A lightweight, flexible, and performant state management library for React applications, built on hooks.
React Context API: A feature in React that enables components to share state without explicitly passing props down through every level of the component tree.
Middleware (Zustand): Functions that wrap the store's set method, allowing for enhanced functionality such as logging, persistence, or immutable state updates.
Selector (Zustand): A function used with the useStore hook to extract specific pieces of state, ensuring components only re-render when those particular pieces of state change.
Shallow Equality: A comparison method that checks if two objects have the same keys and if the values of those keys are strictly equal, but does not recursively compare nested objects.
Immer: A JavaScript library that simplifies working with immutable data structures by letting you write code as if you were mutating data directly, while it handles the creation of new immutable states behind the scenes.
Persist Middleware: A Zustand middleware that automatically saves and loads the store's state to and from a chosen storage mechanism, such as local storage.

Key Takeaways

  • Zustand offers a simpler and more performant alternative to React Context for global state management.
  • By default, React Context re-renders all consumers when any part of the context changes, leading to performance issues.
  • Zustand's create function returns a custom hook that provides access to the store's state and a set function for updates.
  • Use selectors with useCounterStore to ensure components only re-render when the specific data they depend on changes.
  • For selecting multiple values or objects, use shallow from zustand/shallow as a comparator to prevent unnecessary re-renders.
  • Zustand stores can be accessed and updated outside of React components, offering great flexibility for various application architectures.
  • The persist middleware (zustand/middleware) allows for easy state persistence to local storage or other custom storage solutions.
  • The immer middleware (zustand/middleware/immer) simplifies managing deeply nested state by allowing direct mutation of a draft state, which is then immutably updated.

Resources