Next.js course Β· Module 9: Integrations and Advanced Features

Next.js with Zustand - Modern state management

20 min read
In this lesson11

The cart in the Metropolis Quantum store has to be visible in the header, in the product drawer and on the checkout page. Passing it through props across five levels of components quickly turns into a tangle of cables. Server Components fetch data from the server, but purely client-side state, such as a cart, a theme or notifications, needs a shared store. Neo-Bot, the state keeper of the Metropolis, recommends Zustand for this - a minimalist but powerful state management library.

What is Zustand?

Zustand is a modern state management library that offers:

  • Minimalist API - simple and intuitive API
  • TypeScript-first - full typing support
  • No boilerplate - no template code
  • SSR-friendly - support for Server-Side Rendering
  • DevTools support - integration with Redux DevTools
  • Middleware ecosystem - a rich middleware ecosystem

Advantages of Zustand over Redux

  • Less code - no actions, reducers or dispatchers
  • Better TypeScript - natural type support
  • Modular design - easy store splitting
  • Performance - automatic re-render optimization

The most important difference: Zustand needs no provider wrapping the app and no reducers. A store is a plain hook that you import wherever you need it.

Installation and basic setup

Installation

Setup always follows the same order: install the package, create the store with create(), define the state and actions, and finally use the hook in a component. We start with the installation:

1# Install Zustand
2npm install zustand
3
4# For development tools (optional)
5npm install @redux-devtools/extension

The second package is optional. To inspect the state, the Redux DevTools browser extension plus the devtools middleware you will meet shortly are enough.

Basic store

First we describe the shape of the store with the CounterState interface: one count value and three actions. The create function returns a hook, and set merges the object you pass into the state:

1// stores/counterStore.ts
2import { create } from 'zustand';
3
4interface CounterState {
5  count: number;
6  increment: () => void;
7  decrement: () => void;
8  reset: () => void;
9}
10
11export const useCounterStore = create<CounterState>()((set) => ({
12  count: 0,
13  increment: () => set((state) => ({ count: state.count + 1 })),
14  decrement: () => set((state) => ({ count: state.count - 1 })),
15  reset: () => set({ count: 0 }),
16}));

Notice the double parentheses create<CounterState>()(...): this is the recommended TypeScript form that lets types be inferred correctly. Actions are plain functions in the same object as the data, with no dispatch at all.

Using it in a component

The useCounterStore hook works only in client components, which is why the file starts with 'use client':

1// components/Counter.tsx
2'use client';
3
4import { useCounterStore } from '@/stores/counterStore';
5
6export default function Counter() {
7  const { count, increment, decrement, reset } = useCounterStore();
8
9  return (
10    <div className="bg-white p-6 rounded-lg shadow">
11      <h2 className="text-2xl font-bold mb-4">Counter: {count}</h2>
12
13      <div className="space-x-4">
14        <button
15          onClick={increment}
16          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
17        >
18          +1
19        </button>
20
21        <button
22          onClick={decrement}
23          className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
24        >
25          -1
26        </button>
27
28        <button
29          onClick={reset}
30          className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
31        >
32          Reset
33        </button>
34      </div>
35    </div>
36  );
37}

The counter works without any provider in the tree. This form reads the whole store, though, so the component re-renders on every change, and in the performance section you will see how selectors fix that.

Advanced patterns

User store with authentication

A real store also keeps loading state and errors. The persist middleware saves a chosen part of the state to localStorage, and get lets you read the state inside an action:

1// stores/userStore.ts
2import { create } from 'zustand';
3import { persist, createJSONStorage } from 'zustand/middleware';
4
5interface User {
6  id: string;
7  name: string;
8  email: string;
9  avatar?: string;
10  role: 'user' | 'admin';
11}
12
13interface UserState {
14  user: User | null;
15  isLoading: boolean;
16  error: string | null;
17  login: (email: string, password: string) => Promise<void>;
18  logout: () => void;
19  updateProfile: (data: Partial<User>) => Promise<void>;
20  setUser: (user: User) => void;
21  clearError: () => void;
22}
23
24export const useUserStore = create<UserState>()(
25  persist(
26    (set, get) => ({
27      user: null,
28      isLoading: false,
29      error: null,
30
31      login: async (email: string, password: string) => {
32        set({ isLoading: true, error: null });
33
34        try {
35          const response = await fetch('/api/auth/login', {
36            method: 'POST',
37            headers: { 'Content-Type': 'application/json' },
38            body: JSON.stringify({ email, password }),
39          });
40
41          if (!response.ok) {
42            throw new Error('Invalid login credentials');
43          }
44
45          const userData = await response.json();
46          set({ user: userData.user, isLoading: false });
47        } catch (error) {
48          set({
49            error: error instanceof Error ? error.message : 'An error occurred',
50            isLoading: false
51          });
52        }
53      },
54
55      logout: () => {
56        set({ user: null, error: null });
57        // Call logout API
58        fetch('/api/auth/logout', { method: 'POST' });
59      },
60
61      updateProfile: async (data: Partial<User>) => {
62        const { user } = get();
63        if (!user) return;
64
65        set({ isLoading: true });
66
67        try {
68          const response = await fetch('/api/user/profile', {
69            method: 'PATCH',
70            headers: { 'Content-Type': 'application/json' },
71            body: JSON.stringify(data),
72          });
73
74          if (!response.ok) {
75            throw new Error('Failed to update profile');
76          }
77
78          const updatedUser = await response.json();
79          set({ user: updatedUser, isLoading: false });
80        } catch (error) {
81          set({
82            error: error instanceof Error ? error.message : 'An error occurred',
83            isLoading: false
84          });
85        }
86      },
87
88      setUser: (user: User) => set({ user }),
89      clearError: () => set({ error: null }),
90    }),
91    {
92      name: 'user-storage',
93      storage: createJSONStorage(() => localStorage),
94      partialize: (state) => ({ user: state.user }), // Persist only user data
95    }
96  )
97);

Async actions are just async functions, so Zustand needs no thunk-style middleware. The partialize option saves only user, without the loading and error flags. Watch out for SSR: the server cannot see localStorage, so state restored on the client may differ from the server HTML.

Shopping cart store

The cart shows how to change arrays without mutating them. The type Omit<CartItem, 'quantity'> means a product without the quantity field, because the store sets the quantity:

1// stores/cartStore.ts
2import { create } from 'zustand';
3import { persist } from 'zustand/middleware';
4
5interface CartItem {
6  id: string;
7  name: string;
8  price: number;
9  quantity: number;
10  image?: string;
11  variant?: {
12    size?: string;
13    color?: string;
14  };
15}
16
17interface CartState {
18  items: CartItem[];
19  isOpen: boolean;
20  addItem: (item: Omit<CartItem, 'quantity'>) => void;
21  removeItem: (id: string) => void;
22  updateQuantity: (id: string, quantity: number) => void;
23  clearCart: () => void;
24  toggleCart: () => void;
25  getTotalItems: () => number;
26  getTotalPrice: () => number;
27}
28
29export const useCartStore = create<CartState>()(
30  persist(
31    (set, get) => ({
32      items: [],
33      isOpen: false,
34
35      addItem: (newItem) => {
36        const { items } = get();
37        const existingItem = items.find(item =>
38          item.id === newItem.id &&
39          JSON.stringify(item.variant) === JSON.stringify(newItem.variant)
40        );
41
42        if (existingItem) {
43          set({
44            items: items.map(item =>
45              item === existingItem
46                ? { ...item, quantity: item.quantity + 1 }
47                : item
48            ),
49          });
50        } else {
51          set({
52            items: [...items, { ...newItem, quantity: 1 }],
53          });
54        }
55      },
56
57      removeItem: (id) => {
58        set({
59          items: get().items.filter(item => item.id !== id),
60        });
61      },
62
63      updateQuantity: (id, quantity) => {
64        if (quantity <= 0) {
65          get().removeItem(id);
66          return;
67        }
68
69        set({
70          items: get().items.map(item =>
71            item.id === id ? { ...item, quantity } : item
72          ),
73        });
74      },
75
76      clearCart: () => set({ items: [] }),
77
78      toggleCart: () => set({ isOpen: !get().isOpen }),
79
80      getTotalItems: () => {
81        return get().items.reduce((total, item) => total + item.quantity, 0);
82      },
83
84      getTotalPrice: () => {
85        return get().items.reduce(
86          (total, item) => total + item.price * item.quantity,
87          0
88        );
89      },
90    }),
91    {
92      name: 'cart-storage',
93    }
94  )
95);

addItem increases the quantity if the product in the same variant is already in the cart, and otherwise adds a new line. The original array never changes: map and filter always create a new one, which is how React detects the change.

Middleware and DevTools

Redux DevTools integration

Zustand's built-in middleware include devtools, persist, immer, subscribeWithSelector, combine and redux. devtools shows every state change in the Redux DevTools extension:

1// stores/appStore.ts
2import { create } from 'zustand';
3import { devtools } from 'zustand/middleware';
4
5interface AppState {
6  theme: 'light' | 'dark';
7  sidebarOpen: boolean;
8  notifications: Array<{
9    id: string;
10    type: 'info' | 'success' | 'warning' | 'error';
11    message: string;
12    timestamp: number;
13  }>;
14  toggleTheme: () => void;
15  toggleSidebar: () => void;
16  addNotification: (notification: Omit<AppState['notifications'][0], 'id' | 'timestamp'>) => void;
17  removeNotification: (id: string) => void;
18}
19
20export const useAppStore = create<AppState>()(
21  devtools(
22    (set, get) => ({
23      theme: 'light',
24      sidebarOpen: false,
25      notifications: [],
26
27      toggleTheme: () => {
28        const newTheme = get().theme === 'light' ? 'dark' : 'light';
29        set({ theme: newTheme });
30
31        // Update document class
32        if (typeof window !== 'undefined') {
33          document.documentElement.classList.toggle('dark', newTheme === 'dark');
34        }
35      },
36
37      toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
38
39      addNotification: (notification) => {
40        const id = Math.random().toString(36).substring(2);
41        const timestamp = Date.now();
42
43        set({
44          notifications: [
45            ...get().notifications,
46            { ...notification, id, timestamp }
47          ]
48        });
49
50        // Auto remove after 5 seconds
51        setTimeout(() => {
52          get().removeNotification(id);
53        }, 5000);
54      },
55
56      removeNotification: (id) => {
57        set({
58          notifications: get().notifications.filter(n => n.id !== id)
59        });
60      },
61    }),
62    {
63      name: 'app-store',
64    }
65  )
66);

The theme, the sidebar and the notifications live in a single app store. A notification disappears by itself after 5 seconds, and typeof window keeps the code from running on the server.

Immer middleware for immutable updates

The immer middleware lets you write changes as if you were mutating the state. It needs a separate package:

1npm install immer

Now state.todos.push(...) inside set is safe, because Immer creates a new copy behind the scenes:

1// stores/todosStore.ts
2import { create } from 'zustand';
3import { immer } from 'zustand/middleware/immer';
4
5interface Todo {
6  id: string;
7  text: string;
8  completed: boolean;
9  priority: 'low' | 'medium' | 'high';
10  dueDate?: Date;
11}
12
13interface TodosState {
14  todos: Todo[];
15  filter: 'all' | 'active' | 'completed';
16  addTodo: (text: string, priority?: Todo['priority']) => void;
17  toggleTodo: (id: string) => void;
18  deleteTodo: (id: string) => void;
19  editTodo: (id: string, text: string) => void;
20  setFilter: (filter: TodosState['filter']) => void;
21  clearCompleted: () => void;
22}
23
24export const useTodosStore = create<TodosState>()(
25  immer((set) => ({
26    todos: [],
27    filter: 'all',
28
29    addTodo: (text, priority = 'medium') =>
30      set((state) => {
31        state.todos.push({
32          id: Math.random().toString(36).substring(2),
33          text,
34          completed: false,
35          priority,
36        });
37      }),
38
39    toggleTodo: (id) =>
40      set((state) => {
41        const todo = state.todos.find((t) => t.id === id);
42        if (todo) {
43          todo.completed = !todo.completed;
44        }
45      }),
46
47    deleteTodo: (id) =>
48      set((state) => {
49        state.todos = state.todos.filter((t) => t.id !== id);
50      }),
51
52    editTodo: (id, text) =>
53      set((state) => {
54        const todo = state.todos.find((t) => t.id === id);
55        if (todo) {
56          todo.text = text;
57        }
58      }),
59
60    setFilter: (filter) =>
61      set((state) => {
62        state.filter = filter;
63      }),
64
65    clearCompleted: () =>
66      set((state) => {
67        state.todos = state.todos.filter((t) => !t.completed);
68      }),
69  }))
70);

Compare toggleTodo with a version without Immer: instead of mapping the whole array you change one field. From the outside nothing changes, components still receive immutable state.

SSR and hydration

Store provider for SSR

A store created at module level is shared by all requests on the server, so one user's data could leak to another. The Zustand docs recommend a separate store per request in Next.js, passed through React Context:

1// providers/StoreProvider.tsx
2'use client';
3
4import { type ReactNode, createContext, useRef, useContext } from 'react';
5import { useStore } from 'zustand';
6import { type UserStore, createUserStore, initUserStore } from '@/stores/userStore';
7
8export type UserStoreApi = ReturnType<typeof createUserStore>;
9
10export const UserStoreContext = createContext<UserStoreApi | undefined>(undefined);
11
12export interface UserStoreProviderProps {
13  children: ReactNode;
14  initialState?: Parameters<typeof initUserStore>[0];
15}
16
17export const UserStoreProvider = ({ children, initialState }: UserStoreProviderProps) => {
18  const storeRef = useRef<UserStoreApi | null>(null);
19
20  if (!storeRef.current) {
21    storeRef.current = createUserStore(initUserStore(initialState));
22  }
23
24  return (
25    <UserStoreContext.Provider value={storeRef.current}>
26      {children}
27    </UserStoreContext.Provider>
28  );
29};
30
31export const useUserStore = <T,>(selector: (store: UserStore) => T): T => {
32  const userStoreContext = useContext(UserStoreContext);
33
34  if (!userStoreContext) {
35    throw new Error('useUserStore must be used within UserStoreProvider');
36  }
37
38  return useStore(userStoreContext, selector);
39};

useRef guarantees that the store is created only once per provider instance. In React 19 useRef requires an initial value, hence null. The useStore hook from the zustand package reads the selected slice of the store from context.

Updated store definition

We now create the store itself with createStore from zustand/vanilla, which returns a store object instead of a hook:

1// stores/userStore.ts (updated for SSR)
2import { createStore } from 'zustand/vanilla';
3
4export interface UserStore {
5  user: User | null;
6  isLoading: boolean;
7  login: (email: string, password: string) => Promise<void>;
8  logout: () => void;
9}
10
11export const initUserStore = (initialState?: Partial<UserStore>): UserStore => {
12  return {
13    user: null,
14    isLoading: false,
15    login: async (email: string, password: string) => {
16      // Implementation
17    },
18    logout: () => {
19      // Implementation
20    },
21    ...initialState,
22  };
23};
24
25export const createUserStore = (initialState: UserStore) => {
26  return createStore<UserStore>()(() => initialState);
27};

initUserStore builds the initial state and createUserStore creates the store. The action logic did not change, only the place where the store is created.

Components with Zustand

Theme toggle component

The theme toggle reads state and an action from the app store:

1// components/ThemeToggle.tsx
2'use client';
3
4import { useAppStore } from '@/stores/appStore';
5import { Moon, Sun } from 'lucide-react';
6
7export default function ThemeToggle() {
8  const { theme, toggleTheme } = useAppStore();
9
10  return (
11    <button
12      onClick={toggleTheme}
13      className="p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
14      aria-label="Toggle theme"
15    >
16      {theme === 'light' ? (
17        <Moon className="w-5 h-5" />
18      ) : (
19        <Sun className="w-5 h-5" />
20      )}
21    </button>
22  );
23}

The component knows nothing about document or the dark class, the store holds all the logic.

Shopping cart drawer

The cart drawer is a bigger component that uses every cart action:

1// components/CartDrawer.tsx
2'use client';
3
4import { useCartStore } from '@/stores/cartStore';
5import { X, Plus, Minus, Trash2 } from 'lucide-react';
6
7export default function CartDrawer() {
8  const {
9    items,
10    isOpen,
11    toggleCart,
12    updateQuantity,
13    removeItem,
14    getTotalPrice,
15    getTotalItems,
16  } = useCartStore();
17
18  if (!isOpen) return null;
19
20  return (
21    <div className="fixed inset-0 z-50">
22      {/* Overlay */}
23      <div
24        className="absolute inset-0 bg-black/50"
25        onClick={toggleCart}
26      />
27
28      {/* Drawer */}
29      <div className="absolute right-0 top-0 h-full w-96 bg-white shadow-xl">
30        <div className="flex items-center justify-between p-4 border-b">
31          <h2 className="text-lg font-semibold">
32            Cart ({getTotalItems()})
33          </h2>
34          <button
35            onClick={toggleCart}
36            className="p-2 hover:bg-gray-100 rounded"
37          >
38            <X className="w-5 h-5" />
39          </button>
40        </div>
41
42        <div className="flex-1 overflow-y-auto p-4">
43          {items.length === 0 ? (
44            <p className="text-gray-500 text-center mt-8">
45              Your cart is empty
46            </p>
47          ) : (
48            <div className="space-y-4">
49              {items.map((item) => (
50                <div key={item.id} className="flex items-center space-x-4 p-4 border rounded">
51                  {item.image && (
52                    <img
53                      src={item.image}
54                      alt={item.name}
55                      className="w-16 h-16 object-cover rounded"
56                    />
57                  )}
58
59                  <div className="flex-1">
60                    <h3 className="font-medium">{item.name}</h3>
61                    <p className="text-gray-600">{item.price} USD</p>
62
63                    {item.variant && (
64                      <p className="text-sm text-gray-500">
65                        {item.variant.size && `Size: ${item.variant.size}`}
66                        {item.variant.color && ` Color: ${item.variant.color}`}
67                      </p>
68                    )}
69                  </div>
70
71                  <div className="flex items-center space-x-2">
72                    <button
73                      onClick={() => updateQuantity(item.id, item.quantity - 1)}
74                      className="p-1 hover:bg-gray-100 rounded"
75                    >
76                      <Minus className="w-4 h-4" />
77                    </button>
78
79                    <span className="w-8 text-center">{item.quantity}</span>
80
81                    <button
82                      onClick={() => updateQuantity(item.id, item.quantity + 1)}
83                      className="p-1 hover:bg-gray-100 rounded"
84                    >
85                      <Plus className="w-4 h-4" />
86                    </button>
87
88                    <button
89                      onClick={() => removeItem(item.id)}
90                      className="p-1 hover:bg-red-100 text-red-600 rounded"
91                    >
92                      <Trash2 className="w-4 h-4" />
93                    </button>
94                  </div>
95                </div>
96              ))}
97            </div>
98          )}
99        </div>
100
101        {items.length > 0 && (
102          <div className="border-t p-4">
103            <div className="flex justify-between mb-4">
104              <span className="font-semibold">Total:</span>
105              <span className="font-semibold">{getTotalPrice()} USD</span>
106            </div>
107
108            <button className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors">
109              Go to checkout
110            </button>
111          </div>
112        )}
113      </div>
114    </div>
115  );
116}

The plus and minus buttons call updateQuantity, and going down to zero removes the line, because that is how we defined the action in the store. The component computes nothing itself, the totals come from getTotalItems and getTotalPrice.

Notifications component

The notification list renders a separate NotificationItem component for each entry:

1// components/Notifications.tsx
2'use client';
3
4import { useEffect } from 'react';
5import { useAppStore } from '@/stores/appStore';
6import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
7
8export default function Notifications() {
9  const { notifications, removeNotification } = useAppStore();
10
11  return (
12    <div className="fixed top-4 right-4 z-50 space-y-2">
13      {notifications.map((notification) => (
14        <NotificationItem
15          key={notification.id}
16          notification={notification}
17          onRemove={() => removeNotification(notification.id)}
18        />
19      ))}
20    </div>
21  );
22}
23
24interface NotificationItemProps {
25  notification: {
26    id: string;
27    type: 'info' | 'success' | 'warning' | 'error';
28    message: string;
29    timestamp: number;
30  };
31  onRemove: () => void;
32}
33
34function NotificationItem({ notification, onRemove }: NotificationItemProps) {
35  const { type, message } = notification;
36
37  const icons = {
38    info: Info,
39    success: CheckCircle,
40    warning: AlertTriangle,
41    error: AlertCircle,
42  };
43
44  const colors = {
45    info: 'bg-blue-50 border-blue-200 text-blue-800',
46    success: 'bg-green-50 border-green-200 text-green-800',
47    warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
48    error: 'bg-red-50 border-red-200 text-red-800',
49  };
50
51  const Icon = icons[type];
52
53  useEffect(() => {
54    const timer = setTimeout(onRemove, 5000);
55    return () => clearTimeout(timer);
56  }, [onRemove]);
57
58  return (
59    <div className={`flex items-center p-4 border rounded-lg shadow-lg ${colors[type]} min-w-80 max-w-md`}>
60      <Icon className="w-5 h-5 mr-3 shrink-0" />
61
62      <div className="flex-1">
63        <p className="text-sm font-medium">{message}</p>
64      </div>
65
66      <button
67        onClick={onRemove}
68        className="ml-3 shrink-0 p-1 hover:bg-white hover:bg-white/20 rounded"
69      >
70        <X className="w-4 h-4" />
71      </button>
72    </div>
73  );
74}

Icons and colors are plain objects picked by notification type. The store already removes a notification after 5 seconds, so the timer in the component is an extra safety net.

API integration

API client with Zustand

An HTTP client can read the state outside React through getState(), without a hook:

1// lib/api-client.ts
2import { useUserStore } from '@/stores/userStore';
3
4class ApiClient {
5  private baseURL: string;
6
7  constructor(baseURL: string) {
8    this.baseURL = baseURL;
9  }
10
11  private async request<T>(
12    endpoint: string,
13    options: RequestInit = {}
14  ): Promise<T> {
15    const { user } = useUserStore.getState();
16
17    const config: RequestInit = {
18      headers: {
19        'Content-Type': 'application/json',
20        ...(user && { Authorization: `Bearer ${user.token}` }),
21        ...options.headers,
22      },
23      ...options,
24    };
25
26    const response = await fetch(`${this.baseURL}${endpoint}`, config);
27
28    if (!response.ok) {
29      throw new Error(`API Error: ${response.status}`);
30    }
31
32    return response.json();
33  }
34
35  get<T>(endpoint: string): Promise<T> {
36    return this.request<T>(endpoint);
37  }
38
39  post<T>(endpoint: string, data: any): Promise<T> {
40    return this.request<T>(endpoint, {
41      method: 'POST',
42      body: JSON.stringify(data),
43    });
44  }
45
46  put<T>(endpoint: string, data: any): Promise<T> {
47    return this.request<T>(endpoint, {
48      method: 'PUT',
49      body: JSON.stringify(data),
50    });
51  }
52
53  delete<T>(endpoint: string): Promise<T> {
54    return this.request<T>(endpoint, {
55      method: 'DELETE',
56    });
57  }
58}
59
60export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL || '');

Every request gets an authorization header when the user is logged in. In a real project the User type then needs a token field.

Products store with an API

The products store combines data fetching, search and filtering:

1// stores/productsStore.ts
2import { create } from 'zustand';
3import { apiClient } from '@/lib/api-client';
4
5interface Product {
6  id: string;
7  name: string;
8  price: number;
9  description: string;
10  images: string[];
11  category: string;
12  inStock: boolean;
13}
14
15interface ProductsState {
16  products: Product[];
17  loading: boolean;
18  error: string | null;
19  searchQuery: string;
20  selectedCategory: string | null;
21  fetchProducts: () => Promise<void>;
22  searchProducts: (query: string) => void;
23  filterByCategory: (category: string | null) => void;
24  getFilteredProducts: () => Product[];
25}
26
27export const useProductsStore = create<ProductsState>()((set, get) => ({
28  products: [],
29  loading: false,
30  error: null,
31  searchQuery: '',
32  selectedCategory: null,
33
34  fetchProducts: async () => {
35    set({ loading: true, error: null });
36
37    try {
38      const products = await apiClient.get<Product[]>('/products');
39      set({ products, loading: false });
40    } catch (error) {
41      set({
42        error: error instanceof Error ? error.message : 'Failed to fetch products',
43        loading: false,
44      });
45    }
46  },
47
48  searchProducts: (query: string) => {
49    set({ searchQuery: query });
50  },
51
52  filterByCategory: (category: string | null) => {
53    set({ selectedCategory: category });
54  },
55
56  getFilteredProducts: () => {
57    const { products, searchQuery, selectedCategory } = get();
58
59    return products.filter((product) => {
60      const matchesSearch = searchQuery === '' ||
61        product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
62        product.description.toLowerCase().includes(searchQuery.toLowerCase());
63
64      const matchesCategory = selectedCategory === null ||
65        product.category === selectedCategory;
66
67      return matchesSearch && matchesCategory;
68    });
69  },
70}));

getFilteredProducts derives the result from the current state instead of keeping a second copy of the list. In Next.js, though, initial data is better fetched in a Server Component, keeping only UI state, like the search phrase, in Zustand.

Performance optimization

Selectors for optimization

A selector picks only the needed slice of the store, so the component re-renders only when that slice changes. When a selector returns a new object, wrap it in useShallow:

1// hooks/useOptimizedSelectors.ts
2import { useShallow } from 'zustand/react/shallow';
3import { useCartStore } from '@/stores/cartStore';
4
5// Good - use selectors
6export const useCartInfo = () => {
7  return useCartStore(
8    useShallow((state) => ({
9      totalItems: state.getTotalItems(),
10      totalPrice: state.getTotalPrice(),
11      itemsCount: state.items.length,
12    }))
13  );
14};
15
16// Good - single values
17export const useCartIsOpen = () => useCartStore((state) => state.isOpen);
18
19// Bad - the entire store
20// export const useEntireStore = () => useCartStore();

Without useShallow every render would create a new object, and Zustand 5 would treat it as a change. A single value, such as isOpen, does not need this safeguard.

Subscription pattern

The subscribe method calls a function on every state change, outside the render cycle. It receives the new and the previous state:

1// hooks/useStoreSubscription.ts
2import { useEffect } from 'react';
3import { useCartStore } from '@/stores/cartStore';
4
5export const useCartAnalytics = () => {
6  useEffect(() => {
7    const unsubscribe = useCartStore.subscribe(
8      (state, prevState) => {
9        const items = state.items;
10        const prevItems = prevState.items;
11        // Track analytics when cart changes
12        if (items.length > prevItems.length) {
13          // Item added
14          console.log('Item added to cart');
15        } else if (items.length < prevItems.length) {
16          // Item removed
17          console.log('Item removed from cart');
18        }
19      }
20    );
21
22    return unsubscribe;
23  }, []);
24};

The hook compares the list length before and after the change, and the function returned by subscribe cleans up the subscription. The variant with a separate selector as the first argument requires the subscribeWithSelector middleware.

Testing

Store testing

A store is a plain hook, so you test it with renderHook from Testing Library, and setState resets the state before each test:

1// __tests__/stores/counterStore.test.ts
2import { renderHook, act } from '@testing-library/react';
3import { useCounterStore } from '@/stores/counterStore';
4
5describe('Counter Store', () => {
6  beforeEach(() => {
7    useCounterStore.setState({ count: 0 });
8  });
9
10  it('should increment count', () => {
11    const { result } = renderHook(() => useCounterStore());
12
13    act(() => {
14      result.current.increment();
15    });
16
17    expect(result.current.count).toBe(1);
18  });
19
20  it('should decrement count', () => {
21    const { result } = renderHook(() => useCounterStore());
22
23    act(() => {
24      result.current.increment();
25      result.current.decrement();
26    });
27
28    expect(result.current.count).toBe(0);
29  });
30
31  it('should reset count', () => {
32    const { result } = renderHook(() => useCounterStore());
33
34    act(() => {
35      result.current.increment();
36      result.current.increment();
37      result.current.reset();
38    });
39
40    expect(result.current.count).toBe(0);
41  });
42});

Every test starts from zero thanks to beforeEach, so results do not leak between tests.

Best practices

1. Store organization

Split state by topic instead of building one store for everything:

1// Good - split stores by topic
2// stores/
3//   β”œβ”€β”€ userStore.ts      // User authentication & profile
4//   β”œβ”€β”€ cartStore.ts      // Shopping cart
5//   β”œβ”€β”€ productsStore.ts  // Products catalog
6//   └── appStore.ts       // Global app state
7
8// Bad - one giant store
9// stores/globalStore.ts  // Everything in one place

Small stores are easier to test, and no component subscribes to data it does not use.

2. TypeScript best practices

Write the state interface before the implementation and pass the type as a generic:

1// Good - define interfaces
2interface UserState {
3  user: User | null;
4  login: (email: string, password: string) => Promise<void>;
5}
6
7// Good - use generic types
8export const useUserStore = create<UserState>()((set) => ({
9  //
10}));

This way TypeScript checks every action and every field.

3. Error handling

Every async action should set loading and store the error in the state:

1// Good - handle errors in the store
2const fetchData = async () => {
3  set({ loading: true, error: null });
4
5  try {
6    const data = await api.fetchData();
7    set({ data, loading: false });
8  } catch (error) {
9    set({
10      error: error instanceof Error ? error.message : 'Unknown error',
11      loading: false
12    });
13  }
14};

The component only reads error and loading, it needs no try/catch of its own.

Summary

Zustand with Next.js is a powerful combination for managing application state:

  1. Minimalist API - simple and readable
  2. TypeScript-first - full type support
  3. SSR-friendly - works with Server-Side Rendering
  4. Performance - automatic optimizations
  5. Middleware - a rich ecosystem of extensions

My advice: fetch server data in Server Components, keep Zustand for UI state, and always read it through selectors. In the next lesson you will learn advanced Server Actions patterns that save data on the server.

Remember: Zustand is the lightweight state store of the Metropolis, a single hook with no provider and no Redux boilerplate.

Code for this lesson: App.tsx
1import React, { useState, useCallback } from 'react';
2
3// Simulated Zustand-like store (Sandpack doesn't support zustand package)
4function createStore<T>(initialState: T) {
5  let state = initialState;
6  const listeners = new Set<() => void>();
7
8  return {
9    getState: () => state,
10    setState: (partial: Partial<T> | ((s: T) => Partial<T>)) => {
11      const nextPartial = typeof partial === 'function' ? partial(state) : partial;
12      state = { ...state, ...nextPartial };
13      listeners.forEach(l => l());
14    },
15    subscribe: (listener: () => void) => {
16      listeners.add(listener);
17      return () => listeners.delete(listener);
18    },
19  };
20}
21
22interface Task {
23  id: number;
24  title: string;
25  completed: boolean;
26  priority: 'low' | 'medium' | 'high';
27}
28
29interface TodoState {
30  tasks: Task[];
31  filter: 'all' | 'active' | 'completed';
32}
33
34const store = createStore<TodoState>({
35  tasks: [
36    { id: 1, title: 'Configure Zustand store', completed: true, priority: 'high' },
37    { id: 2, title: 'Add middleware (devtools, persist)', completed: false, priority: 'medium' },
38    { id: 3, title: 'Implement SSR hydration', completed: false, priority: 'high' },
39    { id: 4, title: 'Write unit tests', completed: false, priority: 'low' },
40  ],
41  filter: 'all',
42});
43
44function useStore<R>(selector: (s: TodoState) => R): R {
45  const [, forceUpdate] = useState(0);
46  React.useEffect(() => {
47    return store.subscribe(() => forceUpdate(c => c + 1));
48  }, []);
49  return selector(store.getState());
50}
51
52const priorityColors = { high: '#f44336', medium: '#ff9800', low: '#4caf50' };
53
54const ZustandDemo = () => {
55  const tasks = useStore(s => s.tasks);
56  const filter = useStore(s => s.filter);
57  const [newTask, setNewTask] = useState('');
58  const [newPriority, setNewPriority] = useState<Task['priority']>('medium');
59  const [showCode, setShowCode] = useState(false);
60
61  const filteredTasks = tasks.filter(t => {
62    if (filter === 'active') return !t.completed;
63    if (filter === 'completed') return t.completed;
64    return true;
65  });
66
67  const addTask = () => {
68    if (!newTask.trim()) return;
69    store.setState(s => ({
70      tasks: [...s.tasks, { id: Date.now(), title: newTask, completed: false, priority: newPriority }]
71    }));
72    setNewTask('');
73  };
74
75  const toggleTask = (id: number) => {
76    store.setState(s => ({
77      tasks: s.tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
78    }));
79  };
80
81  const removeTask = (id: number) => {
82    store.setState(s => ({ tasks: s.tasks.filter(t => t.id !== id) }));
83  };
84
85  const setFilter = (f: TodoState['filter']) => store.setState({ filter: f });
86
87  const stats = {
88    total: tasks.length,
89    completed: tasks.filter(t => t.completed).length,
90    active: tasks.filter(t => !t.completed).length,
91  };
92
93  return (
94    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
95      <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Next.js + Zustand</h1>
96      <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>Lightweight state management</p>
97
98      <div style={{ maxWidth: '600px', margin: '0 auto' }}>
99        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '10px', marginBottom: '16px' }}>
100          {[
101            { label: 'Total', value: stats.total, color: '#7c4dff' },
102            { label: 'Active', value: stats.active, color: '#ff9800' },
103            { label: 'Done', value: stats.completed, color: '#4caf50' },
104          ].map(s => (
105            <div key={s.label} style={{ background: 'rgba(255,255,255,0.05)', padding: '16px', borderRadius: '8px', textAlign: 'center' }}>
106              <div style={{ color: s.color, fontSize: '24px', fontWeight: 'bold' }}>{s.value}</div>
107              <div style={{ color: '#888', fontSize: '12px' }}>{s.label}</div>
108            </div>
109          ))}
110        </div>
111
112        <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
113          <input value={newTask} onChange={e => setNewTask(e.target.value)} placeholder="New task..."
114            onKeyDown={e => e.key === 'Enter' && addTask()}
115            style={{ flex: 1, padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }} />
116          <select value={newPriority} onChange={e => setNewPriority(e.target.value as Task['priority'])}
117            style={{ padding: '10px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }}>
118            <option value="high">High</option>
119            <option value="medium">Medium</option>
120            <option value="low">Low</option>
121          </select>
122          <button onClick={addTask} style={{ padding: '10px 16px', background: '#7c4dff', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer' }}>Add</button>
123        </div>
124
125        <div style={{ display: 'flex', gap: '6px', marginBottom: '12px' }}>
126          {(['all', 'active', 'completed'] as const).map(f => (
127            <button key={f} onClick={() => setFilter(f)}
128              style={{ padding: '6px 14px', background: filter === f ? '#7c4dff' : 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '4px', color: '#fff', cursor: 'pointer', fontSize: '12px' }}>
129              {f.charAt(0).toUpperCase() + f.slice(1)}
130            </button>
131          ))}
132        </div>
133
134        <div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '10px', overflow: 'hidden' }}>
135          {filteredTasks.map(task => (
136            <div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '12px 16px', borderBottom: '1px solid #1a1a2e' }}>
137              <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
138              <span style={{ width: '8px', height: '8px', borderRadius: '50%', background: priorityColors[task.priority] }} />
139              <span style={{ flex: 1, textDecoration: task.completed ? 'line-through' : 'none', color: task.completed ? '#555' : '#fff' }}>{task.title}</span>
140              <button onClick={() => removeTask(task.id)} style={{ background: 'none', border: 'none', color: '#f44336', cursor: 'pointer', fontSize: '16px' }}>x</button>
141            </div>
142          ))}
143          {filteredTasks.length === 0 && <p style={{ textAlign: 'center', color: '#555', padding: '20px' }}>No tasks</p>}
144        </div>
145      </div>
146    </div>
147  );
148};
149
150export default ZustandDemo;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Zustand is a library for:

  2. 2. Which of the following is NOT a built-in Zustand middleware?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Create a Zustand store for managing shopping cart state

  • Code editor

    Implement a store with actions and selectors.

  • Click in order

    Arrange the steps of configuring a Zustand store in a Next.js application in order.

Useful articles