A Guide to State Management in React with Zustand: Lightweight and Boilerplate-Free

Development tutorial - IT technology blog
Development tutorial - IT technology blog

The Frustration of Redux Boilerplate

Once your React project scales past a few thousand lines of code, sharing state across components becomes a familiar headache. Using useState with prop drilling through 4–5 intermediary component layers is messy and error-prone, especially without architectural patterns like Feature-Sliced Design (FSD). Switching to the Context API isn’t much better—every state change triggers a cascade of re-renders across the entire subtree.

Many teams reach for Redux Toolkit as a knee-jerk reaction. While Redux enforces strict patterns, it brings a mountain of action files, reducers, selectors, and Provider configurations. Just to toggle dark mode or store an auth token, you end up writing hundreds of lines of boilerplate.

Using a sledgehammer to crack a nut is how codebases bloat. Most of the time, all you need is a global store: lightweight, free from nested DOM Providers, and smart enough to re-render only when relevant data changes. That is why I have migrated to Zustand for almost all my recent projects.

How Does Zustand Work Under the Hood?

Created by the Poimandres collective (the team behind React Three Fiber), Zustand takes a pragmatic, hook-based approach:

  • The Store Is Simply a Custom Hook: You bundle all state and mutation actions into a single function. Whenever you need data, just call the hook.
  • Zero Context Providers: No need to wrap your <App /> component with <Provider>. Your component tree stays clean, and writing unit tests becomes effortless because components no longer depend on parent contexts.
  • Optimized Selective Re-rendering: Components only re-render when the specific slice of state selected actually changes, completely bypassing the cascading re-render flaw of the Context API.
  • Tiny ~1.1KB Bundle Size: Significantly lighter than the tens of kilobytes required by Redux Toolkit, offering the kind of lean footprint modern apps target during bundle size optimization.

Step-by-Step Practical Guide to Zustand

1. Installing the Library

Run the following command in your project terminal (Vite or Next.js):

npm install zustand
# or if using pnpm:
pnpm add zustand

2. Creating a Store

Create a file named src/stores/useCartStore.js to manage shopping cart state. State values and updater functions live neatly in one place:

import { create } from 'zustand';

export const useCartStore = create((set) => ({
  items: [],
  totalCount: 0,

  // Action to add an item
  addItem: (product) => set((state) => ({
    items: [...state.items, product],
    totalCount: state.totalCount + 1
  })),

  // Action to clear the cart
  clearCart: () => set({ items: [], totalCount: 0 })
}));

The set function performs an automatic shallow merge at the root level. You only need to pass the fields you want to update without manually spreading the entire previous state.

3. Consuming the Store and Preventing Unnecessary Re-renders

For optimal performance, always subscribe to specific state slices via selectors rather than pulling in the entire store:

import React from 'react';
import { useCartStore } from '../stores/useCartStore';

// Cart badge: re-renders only when totalCount changes
export const CartBadge = () => {
  const totalCount = useCartStore((state) => state.totalCount);
  return <span className="badge">Cart: {totalCount}</span>;
};

// Product card: subscribes only to addItem, completely immune to totalCount changes
export const ProductItem = ({ product }) => {
  const addItem = useCartStore((state) => state.addItem);

  return (
    <div className="product-card">
      <h4>{product.name}</h4>
      <p>Price: ${product.price.toLocaleString('en-US')}</p>
      <button onClick={() => addItem(product)}>Add to Cart</button>
    </div>
  );
};

Whenever ‘Add to Cart’ is clicked, totalCount increments. Only CartBadge re-renders. ProductItem never re-renders because it only subscribes to the stable addItem function.

4. Handling Asynchronous Actions

Forget about Redux Thunk or Redux Saga. With Zustand, async functions are declared directly inside the store definition:

export const useUserStore = create((set) => ({
  userData: null,
  isLoading: false,
  error: null,

  fetchUserProfile: async (userId) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch(`https://api.example.com/users/${userId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      set({ userData: data, isLoading: false });
    } catch (err) {
      set({ error: err.message, isLoading: false });
    }
  }
}));

Pro tip: When mocking schemas or inspecting API JSON responses before wiring them into the store, lightweight online formatters like toolcraft.app can save you time compared to manual DevTools tweaking. For finite multi-step workflows like checkout or auth sequences, pair your state with managing complex logic with XState to prevent conflicting flags.

5. Auto-persisting State to LocalStorage

Want to preserve the UI theme or shopping cart across page reloads? Just wrap your store with the persist middleware:

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

export const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () => set((state) => ({
        theme: state.theme === 'light' ? 'dark' : 'light'
      }))
    }),
    {
      name: 'app-settings-storage', // storage key in localStorage
    }
  )
);

Conclusion

Zustand delivers a much smoother developer experience compared to the overhead of Redux. You get the ergonomic simplicity of standard React hooks alongside fine-grained control over component re-renders. When building robust React interfaces alongside modern tooling like TanStack Router, give Zustand a try on your next project—you’ll immediately notice the difference.

Share: