Zustand Tutorial: Lightweight State Management for React - Nayaka Yoga Pradipta
ID | EN

Zustand Tutorial: Lightweight State Management for React

Tuesday, Dec 23, 2025

If you’ve ever used Redux, you know how complicated the setup can be: reducers, actions, action types, middleware… Not to mention the boilerplate that needs to be written repeatedly.

Zustand comes as a much simpler alternative. The name “Zustand” comes from German meaning “state”. And as the name suggests, this library focuses on one thing: straightforward state management.

Why Do We Need State Management?

Before discussing Zustand, let’s understand why state management is important.

In React, passing data between components uses props. But as applications get more complex, you’ll encounter the prop drilling problem:

// Prop drilling nightmare 😱
function App() {
  const [user, setUser] = useState(null);
  return <Layout user={user} setUser={setUser} />;
}

function Layout({ user, setUser }) {
  return <Sidebar user={user} setUser={setUser} />;
}

function Sidebar({ user, setUser }) {
  return <UserProfile user={user} setUser={setUser} />;
}

function UserProfile({ user, setUser }) {
  // Finally arrived...
  return <div>{user?.name}</div>;
}

State management libraries help you share state to any component without passing props continuously.

What is Zustand?

Zustand is a minimalist but powerful state management library. Created by the same team behind Jotai and React Spring (Poimandres).

// Zustand store in 5 lines
import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

That’s it. No Provider, no reducer, no action type constants.

Why Is Zustand Simpler Than Redux?

AspectReduxZustand
SetupStore + Reducers + Actions + ProviderSingle create() function
BoilerplateMany files and constantsMinimal, can be 1 file
ProviderMust wrap with ProviderNo Provider needed
Learning CurveSteepVery easy
Bundle Size~7KB (Redux + React-Redux)~1KB
DevToolsRequires middlewareBuilt-in support

Installation and Setup

1. Install Package

npm install zustand
# or
pnpm add zustand
# or
yarn add zustand

Done. No additional setup. No need to wrap application with Provider.

src/
├── stores/
│   ├── useUserStore.ts
│   ├── useCartStore.ts
│   └── useThemeStore.ts
├── components/
└── ...

Creating Your First Store

Let’s create a simple counter store:

// filepath: /src/stores/useCounterStore.ts
import { create } from 'zustand';

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

export 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 }),
}));

Code Explanation

  • create<CounterState> - Creates store with TypeScript type
  • set - Function to update state
  • (state) => ({ count: state.count + 1 }) - Update based on previous state
  • set({ count: 0 }) - Direct update without reference to previous state

Using Store in Components

// filepath: /src/components/Counter.tsx
import { useCounterStore } from '../stores/useCounterStore';

export function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div className="flex flex-col items-center gap-4">
      <h1 className="text-4xl font-bold">{count}</h1>
      <div className="flex gap-2">
        <button onClick={decrement}>-</button>
        <button onClick={reset}>Reset</button>
        <button onClick={increment}>+</button>
      </div>
    </div>
  );
}

Selectors for Performance Optimization

If you only need part of the state, use selectors:

// ❌ This will re-render every time ANY state changes
const state = useCounterStore();

// ✅ This only re-renders when `count` changes
const count = useCounterStore((state) => state.count);

// ✅ Get multiple values at once
const { count, increment } = useCounterStore((state) => ({
  count: state.count,
  increment: state.increment,
}));

Actions and State Updates

More Complex Store Example

// filepath: /src/stores/useTodoStore.ts
import { create } from 'zustand';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  deleteTodo: (id: string) => void;
  setFilter: (filter: 'all' | 'active' | 'completed') => void;
  clearCompleted: () => void;
}

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  filter: 'all',

  addTodo: (text) =>
    set((state) => ({
      todos: [
        ...state.todos,
        { id: crypto.randomUUID(), text, completed: false },
      ],
    })),

  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),

  deleteTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),

  setFilter: (filter) => set({ filter }),

  clearCompleted: () =>
    set((state) => ({
      todos: state.todos.filter((todo) => !todo.completed),
    })),
}));

Computed Values with get

// filepath: /src/stores/useTodoStore.ts
export const useTodoStore = create<TodoState>((set, get) => ({
  todos: [],
  filter: 'all',

  // Getter for filtered todos
  getFilteredTodos: () => {
    const { todos, filter } = get();
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  },

  // Getter for count
  getActiveCount: () => {
    return get().todos.filter((t) => !t.completed).length;
  },

  // ... other actions
}));

Async Actions (API Calls)

Zustand supports async actions natively without additional middleware:

// filepath: /src/stores/useProductStore.ts
import { create } from 'zustand';

interface Product {
  id: number;
  name: string;
  price: number;
}

interface ProductState {
  products: Product[];
  isLoading: boolean;
  error: string | null;
  fetchProducts: () => Promise<void>;
  createProduct: (product: Omit<Product, 'id'>) => Promise<void>;
}

export const useProductStore = create<ProductState>((set, get) => ({
  products: [],
  isLoading: false,
  error: null,

  fetchProducts: async () => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch('/api/products');
      if (!res.ok) throw new Error('Failed to fetch');
      const products = await res.json();
      set({ products, isLoading: false });
    } catch (error) {
      set({ error: (error as Error).message, isLoading: false });
    }
  },

  createProduct: async (product) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product),
      });
      if (!res.ok) throw new Error('Failed to create');
      const newProduct = await res.json();
      set((state) => ({
        products: [...state.products, newProduct],
        isLoading: false,
      }));
    } catch (error) {
      set({ error: (error as Error).message, isLoading: false });
    }
  },
}));

Using Async Store

// filepath: /src/components/ProductList.tsx
import { useEffect } from 'react';
import { useProductStore } from '../stores/useProductStore';

export function ProductList() {
  const { products, isLoading, error, fetchProducts } = useProductStore();

  useEffect(() => {
    fetchProducts();
  }, [fetchProducts]);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.name} - ${product.price.toLocaleString()}
        </li>
      ))}
    </ul>
  );
}

Persist State to localStorage

Zustand has a persist middleware for saving state to localStorage:

// filepath: /src/stores/useThemeStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface ThemeState {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light',
        })),
    }),
    {
      name: 'theme-storage', // Key in localStorage
    }
  )
);

Complete Persist Options

// filepath: /src/stores/useUserStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface UserState {
  user: { name: string; email: string } | null;
  token: string | null;
  login: (user: { name: string; email: string }, token: string) => void;
  logout: () => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      user: null,
      token: null,

      login: (user, token) => set({ user, token }),
      logout: () => set({ user: null, token: null }),
    }),
    {
      name: 'user-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        user: state.user,
        token: state.token,
      }),
    }
  )
);

DevTools Integration

Zustand supports Redux DevTools for debugging:

// filepath: /src/stores/useCounterStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set(
          (state) => ({ count: state.count + 1 }),
          false,
          'increment' // Action name in DevTools
        ),
      decrement: () =>
        set(
          (state) => ({ count: state.count - 1 }),
          false,
          'decrement'
        ),
    }),
    { name: 'CounterStore' }
  )
);

Combining Multiple Middleware

// filepath: /src/stores/useCartStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  getTotalPrice: () => number;
}

export const useCartStore = create<CartState>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],

        addItem: (item) =>
          set(
            (state) => {
              const existing = state.items.find((i) => i.id === item.id);
              if (existing) {
                return {
                  items: state.items.map((i) =>
                    i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
                  ),
                };
              }
              return { items: [...state.items, { ...item, quantity: 1 }] };
            },
            false,
            'addItem'
          ),

        removeItem: (id) =>
          set(
            (state) => ({
              items: state.items.filter((i) => i.id !== id),
            }),
            false,
            'removeItem'
          ),

        clearCart: () => set({ items: [] }, false, 'clearCart'),

        getTotalPrice: () => {
          return get().items.reduce(
            (total, item) => total + item.price * item.quantity,
            0
          );
        },
      }),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);

Zustand vs Redux vs Jotai

FeatureZustandRedux ToolkitJotai
Bundle Size~1KB~7KB~3KB
BoilerplateMinimalMediumMinimal
Learning CurveEasyMediumEasy
DevTools
Persist✅ Middleware✅ Middleware✅ Atoms
AsyncNativeRTK Query / ThunkNative
Provider❌ Not needed✅ Required✅ Optional
Atom-based
TypeScript✅ Excellent✅ Good✅ Excellent

When to Use Zustand?

  • Medium-size applications with straightforward state
  • Teams that want to escape Redux complexity
  • Need quick setup without boilerplate
  • State management for side projects or MVPs

When to Use Redux Toolkit?

  • Enterprise applications already using Redux
  • Need RTK Query for data fetching
  • Team already familiar with Redux patterns

When to Use Jotai?

  • Applications with many independent state atoms
  • Prefer bottom-up state composition
  • Need fine-grained reactivity

Best Practices

1. Separate Stores by Domain

// ❌ Don't combine everything in one store
const useStore = create((set) => ({
  user: null,
  cart: [],
  theme: 'light',
  notifications: [],
  // ... too many
}));

// ✅ Separate by domain
const useUserStore = create((set) => ({ ... }));
const useCartStore = create((set) => ({ ... }));
const useThemeStore = create((set) => ({ ... }));

2. Use Selectors for Performance

// ❌ Component re-renders every time state changes
function Component() {
  const store = useStore();
  return <div>{store.count}</div>;
}

// ✅ Only re-renders when count changes
function Component() {
  const count = useStore((state) => state.count);
  return <div>{count}</div>;
}

3. Immer for Nested State

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

interface State {
  user: {
    profile: {
      name: string;
      settings: {
        notifications: boolean;
      };
    };
  };
  updateNotificationSetting: (value: boolean) => void;
}

const useStore = create<State>()(
  immer((set) => ({
    user: {
      profile: {
        name: 'John',
        settings: {
          notifications: true,
        },
      },
    },
    updateNotificationSetting: (value) =>
      set((state) => {
        state.user.profile.settings.notifications = value;
      }),
  }))
);

4. Testing Store

// filepath: /src/stores/__tests__/useCounterStore.test.ts
import { useCounterStore } from '../useCounterStore';

describe('useCounterStore', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 });
  });

  it('should increment count', () => {
    useCounterStore.getState().increment();
    expect(useCounterStore.getState().count).toBe(1);
  });

  it('should decrement count', () => {
    useCounterStore.setState({ count: 5 });
    useCounterStore.getState().decrement();
    expect(useCounterStore.getState().count).toBe(4);
  });

  it('should reset count', () => {
    useCounterStore.setState({ count: 10 });
    useCounterStore.getState().reset();
    expect(useCounterStore.getState().count).toBe(0);
  });
});

5. Subscribe to State Changes

// Subscribe outside React
const unsubscribe = useStore.subscribe(
  (state) => state.count,
  (count, previousCount) => {
    console.log('Count changed from', previousCount, 'to', count);
  }
);

// Cleanup
unsubscribe();

Conclusion

Zustand is a refreshing state management library in the React ecosystem. With a minimal but powerful API, you can manage state without dealing with excessive boilerplate.

Key takeaways:

  • Simple API: Just create() and ready to use
  • No Provider: No need to wrap application
  • TypeScript First: Excellent type inference
  • Middleware: Persist, DevTools, Immer built-in
  • Lightweight: Only ~1KB gzipped

If you’re still using Redux and feel overwhelmed by the boilerplate, give Zustand a try. You might be surprised how easy state management can be.

Happy coding! 🐻