Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje

Next.js z Zustand - Nowoczesne state management

19 min czytania
W tej lekcji11

Koszyk w sklepie Metropolii Quantum musi być widoczny w nagłówku, w szufladzie z produktami i na stronie kasy. Przekazywanie go propsami przez pięć poziomów komponentów szybko zamienia się w plątaninę kabli. Server Components pobierają dane z serwera, ale stan czysto kliencki, taki jak koszyk, motyw czy powiadomienia, potrzebuje wspólnego magazynu. Neo-Bot, zarządca stanu Metropolii, poleca do tego Zustand - minimalistyczną, ale potężną bibliotekę do zarządzania stanem.

Czym jest Zustand?

Zustand to nowoczesna biblioteka state management, która oferuje:

  • Minimalist API - proste i intuicyjne API
  • TypeScript-first - pełne wsparcie typowania
  • No boilerplate - brak kodu szablonowego
  • SSR-friendly - wsparcie dla Server-Side Rendering
  • DevTools support - integracja z Redux DevTools
  • Middleware ecosystem - bogaty ekosystem middleware

Zalety Zustand nad Redux

  • Mniej kodu - brak akcji, reducerów i dispatcherów
  • Lepsze TypeScript - naturalne wsparcie typów
  • Modular design - łatwe dzielenie store'ów
  • Performance - automatyczna optymalizacja re-renderów

Najważniejsza różnica: Zustand nie wymaga providera owijającego aplikację ani reducerów. Store to zwykły hook, który importujesz tam, gdzie go potrzebujesz.

Instalacja i podstawowa konfiguracja

Instalacja

Konfiguracja zawsze przebiega w tej samej kolejności: instalacja pakietu, utworzenie store'a funkcją create(), zdefiniowanie stanu i akcji, a na końcu użycie hooka w komponencie. Zaczynamy od instalacji:

1# Zainstaluj Zustand
2npm install zustand
3
4# Dla development tools (opcjonalne)
5npm install @redux-devtools/extension

Drugi pakiet jest opcjonalny. Do podglądu stanu wystarczy rozszerzenie przeglądarki Redux DevTools i middleware devtools, które poznasz za chwilę.

Podstawowy store

Najpierw opisujemy kształt store'a interfejsem CounterState: jedna wartość count i trzy akcje. Funkcja create zwraca hook, a set scala przekazany obiekt ze stanem:

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

Zwróć uwagę na podwójne nawiasy create<CounterState>()(...): to zalecany zapis w TypeScript, który pozwala poprawnie wywnioskować typy. Akcje to zwykłe funkcje w tym samym obiekcie co dane, bez żadnego dispatch.

Użycie w komponencie

Hook useCounterStore działa tylko w komponentach klienckich, dlatego plik zaczyna się od '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">Licznik: {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}

Licznik działa bez providera w drzewie. Ten zapis pobiera jednak cały store, więc komponent odświeża się przy każdej zmianie, a w sekcji o wydajności zobaczysz, jak to poprawić selektorami.

Zaawansowane patterns

User store z autentykacją

Prawdziwy store przechowuje też stan ładowania i błędy. Middleware persist zapisuje wybraną część stanu w localStorage, a get pozwala odczytać stan wewnątrz akcji:

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('Nieprawidłowe dane logowania');
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 : 'Wystąpił błąd',
50            isLoading: false
51          });
52        }
53      },
54
55      logout: () => {
56        set({ user: null, error: null });
57        // Wywołaj API logout
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('Nie udało się zaktualizować profilu');
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 : 'Wystąpił błąd',
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 tylko user data
95    }
96  )
97);

Akcje asynchroniczne to po prostu funkcje async, więc Zustand nie potrzebuje middleware typu thunk. Opcja partialize zapisuje tylko user, bez flag ładowania i błędów. Uwaga na SSR: serwer nie widzi localStorage, więc stan odtworzony po stronie klienta może różnić się od HTML-a z serwera.

Shopping cart store

Koszyk pokazuje, jak zmieniać tablice bez mutowania. Typ Omit<CartItem, 'quantity'> oznacza produkt bez pola ilości, bo ilość ustala store:

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 zwiększa ilość, jeśli produkt w tym samym wariancie już jest w koszyku, a w przeciwnym razie dodaje nową pozycję. Oryginalna tablica nigdy się nie zmienia: map i filter zawsze tworzą nową, dzięki czemu React wykrywa zmianę.

Middleware i DevTools

Redux DevTools integration

Wbudowane middleware Zustand to m.in. devtools, persist, immer, subscribeWithSelector, combine i redux. devtools pokazuje każdą zmianę stanu w rozszerzeniu Redux DevTools:

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);

Motyw, panel boczny i powiadomienia trafiają do jednego store'a aplikacji. Powiadomienie samo znika po 5 sekundach, a typeof window chroni kod przed uruchomieniem na serwerze.

Immer middleware dla immutable updates

Middleware immer pozwala pisać zmiany tak, jakbyś mutował stan. Wymaga osobnego pakietu:

1npm install immer

Teraz state.todos.push(...) w środku set jest bezpieczne, bo Immer tworzy nową kopię za kulisami:

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);

Porównaj toggleTodo z wersją bez Immera: zamiast mapowania całej tablicy zmieniasz jedno pole. Na zewnątrz nic się nie zmienia, komponenty dalej dostają niemutowalny stan.

SSR i hydration

Store provider dla SSR

Store utworzony na poziomie modułu jest na serwerze wspólny dla wszystkich żądań, więc dane jednego użytkownika mogłyby trafić do drugiego. Dokumentacja Zustand zaleca w Next.js osobny store na każde żądanie, przekazywany przez 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 gwarantuje, że store powstanie tylko raz na instancję providera. W React 19 useRef wymaga wartości początkowej, stąd null. Hook useStore z paczki zustand czyta wybrany fragment store'a z kontekstu.

Updated store definition

Sam store tworzymy teraz funkcją createStore z zustand/vanilla, która zwraca obiekt store'a zamiast hooka:

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 składa stan początkowy, a createUserStore tworzy store. Logika akcji się nie zmieniła, zmienia się tylko miejsce powstawania store'a.

Komponenty z Zustand

Theme toggle component

Przełącznik motywu czyta stan i akcję ze store'a aplikacji:

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}

Komponent nie wie nic o document ani o klasie dark, całą logikę trzyma store.

Shopping cart drawer

Szuflada koszyka to większy komponent, który korzysta ze wszystkich akcji koszyka:

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            Koszyk ({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              Koszyk jest pusty
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} zł</p>
62
63                    {item.variant && (
64                      <p className="text-sm text-gray-500">
65                        {item.variant.size && `Rozmiar: ${item.variant.size}`}
66                        {item.variant.color && ` Kolor: ${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">Łącznie:</span>
105              <span className="font-semibold">{getTotalPrice()} zł</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              Przejdź do kasy
110            </button>
111          </div>
112        )}
113      </div>
114    </div>
115  );
116}

Przyciski plus i minus wołają updateQuantity, a zejście do zera usuwa pozycję, bo tak zdefiniowaliśmy akcję w store. Komponent niczego nie liczy sam, sumy dają getTotalItems i getTotalPrice.

Notifications component

Lista powiadomień renderuje osobny komponent NotificationItem dla każdego wpisu:

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}

Ikony i kolory są zwykłymi obiektami wybieranymi po typie powiadomienia. Store już usuwa powiadomienie po 5 sekundach, więc timer w komponencie jest tu dodatkowym zabezpieczeniem.

API integration

API client z Zustand

Klient HTTP może czytać stan poza Reactem przez getState(), bez hooka:

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

Każde żądanie dostaje nagłówek autoryzacji, jeśli użytkownik jest zalogowany. W prawdziwym projekcie typ User musi mieć wtedy pole token.

Products store z API

Store produktów łączy pobieranie danych, wyszukiwanie i filtrowanie:

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 wylicza wynik z aktualnego stanu, zamiast trzymać drugą kopię listy. W Next.js dane startowe lepiej jednak pobrać w Server Component, a w Zustand trzymać tylko stan interfejsu, jak fraza wyszukiwania.

Performance optimization

Selectors dla optymalizacji

Selektor wybiera ze store'a tylko potrzebny fragment, więc komponent odświeża się tylko przy jego zmianie. Gdy selektor zwraca nowy obiekt, owiń go w useShallow:

1// hooks/useOptimizedSelectors.ts
2import { useShallow } from 'zustand/react/shallow';
3import { useCartStore } from '@/stores/cartStore';
4
5// Dobrze - używaj selektorów
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// Dobrze - pojedyncze wartości
17export const useCartIsOpen = () => useCartStore((state) => state.isOpen);
18
19// Źle - cały store
20// export const useEntireStore = () => useCartStore();

Bez useShallow każdy render tworzyłby nowy obiekt, a Zustand 5 uznałby go za zmianę. Pojedyncza wartość, jak isOpen, nie potrzebuje tego zabezpieczenia.

Subscription pattern

Metoda subscribe wywołuje funkcję przy każdej zmianie stanu, poza cyklem renderowania. Dostaje nowy i poprzedni stan:

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};

Hook porównuje długość listy przed i po zmianie, a funkcja zwrócona z subscribe sprząta subskrypcję. Wariant z osobnym selektorem jako pierwszym argumentem wymaga middleware subscribeWithSelector.

Testing

Store testing

Store to zwykły hook, więc testujesz go przez renderHook z Testing Library, a setState resetuje stan przed każdym testem:

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

Każdy test startuje od zera dzięki beforeEach, więc wyniki nie przeciekają między testami.

Najlepsze praktyki

1. Store organization

Dziel stan tematycznie, zamiast budować jeden magazyn na wszystko:

1// Dobrze - podziel store'y tematycznie
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// Źle - jeden gigantyczny store
9// stores/globalStore.ts  // Everything in one place

Małe store'y łatwiej testować i żaden komponent nie subskrybuje danych, których nie używa.

2. TypeScript best practices

Interfejs stanu pisz przed implementacją, a typ przekazuj generykiem:

1// Dobrze - definiuj interfaces
2interface UserState {
3  user: User | null;
4  login: (email: string, password: string) => Promise<void>;
5}
6
7// Dobrze - używaj typów generycznych
8export const useUserStore = create<UserState>()((set) => ({
9  //
10}));

Dzięki temu TypeScript sprawdzi każdą akcję i każde pole.

3. Error handling

Każda akcja asynchroniczna powinna ustawić loading, a błąd zapisać w stanie:

1// Dobrze - obsługuj błędy w 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};

Komponent tylko czyta error i loading, nie potrzebuje własnego try/catch.

Podsumowanie

Zustand z Next.js tworzy potężną kombinację dla zarządzania stanem aplikacji:

  1. Minimalist API - proste i czytelne
  2. TypeScript-first - pełne wsparcie typów
  3. SSR-friendly - działa z Server-Side Rendering
  4. Performance - automatyczne optymalizacje
  5. Middleware - bogaty ekosystem rozszerzeń

Moja rada: dane z serwera pobieraj w Server Components, a Zustand zostaw dla stanu interfejsu, zawsze czytając go selektorami. W następnej lekcji poznasz zaawansowane wzorce Server Actions, które zapisują dane po stronie serwera.

Zapamiętaj: Zustand to lekki magazyn stanu Metropolii, jeden hook bez providera i bez boilerplate'u Reduxa.

Kod do tej lekcji: 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;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Zustand to biblioteka do:

  2. 2. Które z poniższych NIE jest wbudowanym middleware Zustand?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz store Zustand do zarządzania stanem koszyka zakupowego

  • Edytor kodu

    Zaimplementuj store z actions i selectors.

  • Klikanie w kolejności

    Uporządkuj kroki konfiguracji Zustand store w aplikacji Next.js.

Przydatne artykuły