Zustand Tutorial: Lightweight State Management for React
ID | EN

Zustand Tutorial: Lightweight State Management for React

Rabu, 15 Jan 2025

If you’ve ever used Redux, you know how tedious the setup can be: reducers, actions, action types, middleware… Not to mention the boilerplate that has to be written over and over again.

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

Why Do You 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 continuously passing props.

What is Zustand?

Zustand is a minimalist yet 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
DevToolsNeeds 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 the 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 a 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 referencing previous state

Using the 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>
  );
}

Selector for Performance Optimization

If you only need part of the state, use a selector:

// ❌ 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

Zustand supports async actions natively:

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

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (id: string) => Promise<void>;
  logout: () => void;
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (id) => {
    set({ loading: true, error: null });
    try {
      const response = await fetch(`/api/users/${id}`);
      if (!response.ok) throw new Error('Failed to fetch user');
      const user = await response.json();
      set({ user, loading: false });
    } catch (error) {
      set({ error: (error as Error).message, loading: false });
    }
  },

  logout: () => set({ user: null }),
}));

Middleware: Persist, DevTools, Immer

Persist Middleware

Save 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' }
  )
);

DevTools Middleware

Debug with Redux DevTools:

// 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 that already use Redux
  • Need RTK Query for data fetching
  • Teams 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 things
}));

// ✅ 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 Stores

// 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 of 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 yet powerful API, you can manage state without dealing with excessive boilerplate.

Key takeaways:

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

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

Happy coding!