Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Udostępnij Udostępnij Kontakt
Wprowadzenie

Przez całą serię zarządzaliśmy stanem lokalnym komponentów (useState, useReducer) oraz współdzielonym stanem przez Context API. To działa świetnie dla małych i średnich aplikacji. Ale gdy aplikacja rośnie, Context API może prowadzić do problemów z wydajnością (każda zmiana re-renderuje wszystkie komponenty), a zarządzanie wieloma kontekstami staje się uciążliwe.

🔍 Co to znaczy dla początkujących?

Wyobraź sobie aplikację sklepu internetowego:

  • Stan lokalny (useState) - dane dla jednego komponentu (np. otwarty/zamknięty dropdown)
  • Context API - dane współdzielone (np. dane zalogowanego użytkownika, koszyk zakupów)

Problem Context API: Gdy zmieniasz COKOLWIEK w context (np. dodajesz produkt do koszyka), WSZYSTKIE komponenty używające tego kontekstu muszą się przerysować - nawet te, które pokazują tylko nazwę użytkownika (która się nie zmieniła)! To jak włączanie wszystkich świateł w domu, gdy potrzebujesz tylko jednego.

Redux był przez lata standardem dla globalnego state management, ale jest verbose, wymaga dużo boilerplate i ma stromą krzywą uczenia. Na szczęście mamy prostsze rozwiązanie: Zustand – minimalistyczna biblioteka state management, która jest prosta jak useState, ale działa globalnie.

🔍 Wyjaśnienie terminów:
  • verbose - rozwlekły, wymaga dużo kodu do zrobienia prostych rzeczy
  • boilerplate - powtarzalny kod, który musisz pisać za każdym razem (nudny i męczący)
  • stroma krzywa uczenia - trudno się nauczyć, wymaga sporo czasu i wysiłku
  • state management - zarządzanie stanem = przechowywanie i aktualizowanie danych w aplikacji

W tym wpisie nauczymy się Zustand od podstaw, poznamy zaawansowane wzorce, zintegrujemy z TypeScript, połączymy z localStorage i API, a także zobaczymy jak zastępuje Context API + useReducer. To będzie praktyczny wpis – po nim będziesz mógł zarządzać globalnym stanem w każdej aplikacji!

Dlaczego Zustand?

Problem z Context API

// ❌ Problem - każda zmiana w Context re-renderuje WSZYSTKIE konsumery
const AppContext = createContext<State>(initialState);

function App() {
  const [user, setUser] = useState(null);
  const [cart, setCart] = useState([]);
  const [theme, setTheme] = useState('light');
  
  return (
    <AppContext.Provider value={{ user, cart, theme }}>
      {/* Zmiana user re-renderuje WSZYSTKO co używa kontekstu */}
    </AppContext.Provider>
  );
}
Problem: Każda zmiana state powoduje re-render WSZYSTKICH komponentów używających context, nawet jeśli używają tylko jednego pola.
🔍 Problem Context API - konkretny przykład:

Masz 3 komponenty używające tego samego contextu:

// Komponent 1 - pokazuje nazwę użytkownika
function UserName() {
  const { user } = useContext(AppContext);
  return <div>{user.name}</div>;
}

// Komponent 2 - pokazuje liczbę produktów w koszyku
function CartBadge() {
  const { cart } = useContext(AppContext);
  return <span>{cart.length}</span>;
}

// Komponent 3 - zmienia motyw
function ThemeToggle() {
  const { theme, setTheme } = useContext(AppContext);
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
    Toggle theme
  </button>;
}

Gdy klikasz przycisk "Toggle theme", zmieniasz tylko theme, ale:

  • ❌ UserName się przerysowuje (niepotrzebnie!)
  • ❌ CartBadge się przerysowuje (niepotrzebnie!)
  • ✅ ThemeToggle się przerysowuje (to potrzebne)

To marnowanie mocy obliczeniowej! W dużych aplikacjach = wolne działanie. 🐌

Rozwiązanie: Zustand

Zustand to minimalistyczna biblioteka state management:

  • 🪶 Lekka (1KB gzipped)
  • 🎯 Proste API
  • 🚀 Bez boilerplate
  • ⚡ Wydajna (selektory)
  • 📘 TypeScript first-class support
  • 🪝 Działa z Hooks
🔍 Co te ikony oznaczają?
  • 1KB gzipped - super mała biblioteka (nie spowalnia ładowania strony)
  • Proste API - mało funkcji do nauczenia, wszystko intuicyjne
  • Bez boilerplate - piszesz TYLKO to co potrzebne, zero zbędnego kodu
  • Selektory - możesz wybrać TYLKO te dane których potrzebujesz (rozwiązuje problem Context!)
  • TypeScript first-class - świetnie działa z TypeScript od razu, bez dodatkowych bibliotek
  • Działa z Hooks - używasz jak zwykłego useState, ale globalnie!
🌟 Przykład z życia: Dlaczego Zustand jest lepszy?

Wyobraź sobie sklep z elektroniką. Masz dane:

  • Zalogowany użytkownik (imię, email)
  • Koszyk (lista produktów)
  • Motyw (jasny/ciemny)
  • Język (PL/EN)

Z Context API: Zmiana języka → wszystkie komponenty przerysowują się (user, koszyk, motyw, język)

Z Zustand: Zmiana języka → przerysowują się TYLKO komponenty które faktycznie wyświetlają teksty w różnych językach!

To jak mądry system oświetlenia - zapala się tylko tam, gdzie jest potrzeba! 💡

💪 Ćwiczenie 1: Analiza problemu

Przeanalizuj swoją ostatnią aplikację (lub pomyśl o aplikacji którą znasz):

  1. Jakie dane są współdzielone między komponentami?
  2. Które komponenty często się przerysowują?
  3. Czy niektóre komponenty przerysowują się niepotrzebnie?
  4. Czy używasz wielu Context providerów? (Ile?)

To pomoże Ci zrozumieć kiedy Zustand będzie przydatny!

Instalacja

npm install zustand

To wszystko! Brak providerów, brak setup, zero boilerplate.

🔍 Co to znaczy "brak providerów"?

Przypomnij sobie Context API:

// Context API - musisz owinąć całą aplikację
<AppContext.Provider>
  <UserContext.Provider>
    <CartContext.Provider>
      <ThemeContext.Provider>
        <App />  {/* Twoja aplikacja gdzieś głęboko! */}
      </ThemeContext.Provider>
    </CartContext.Provider>
  </UserContext.Provider>
</AppContext.Provider>

Z Zustand: Zero providerów! Po prostu importujesz store i używasz. Gotowe! ✨

Podstawy Zustand

Tworzenie store

// stores/counterStore.ts
import { create } from 'zustand';

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

export const useCounterStore = create<CounterState>((set) => ({
  // Stan początkowy
  count: 0,
  
  // Akcje
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));
🔍 Rozbijmy to na czynniki pierwsze:
  1. create<CounterState> - tworzy nowy store z typem TypeScript
  2. (set) => ({ ... }) - funkcja która zwraca początkowy stan + akcje
  3. count: 0 - wartość początkowa (tak jak w useState(0))
  4. increment: () => set(...) - akcja (funkcja zmieniająca stan)
  5. set((state) => ...) - aktualizuje stan na podstawie poprzedniego stanu
  6. set({ count: 0 }) - ustawia stan bez patrzenia na poprzedni

Analogia: Store to jak sejf z przyciskami:

  • count: 0 - początkowa zawartość sejfu
  • increment - przycisk "dodaj 1"
  • decrement - przycisk "odejmij 1"
  • reset - przycisk "wyzeruj"
🌟 Porównanie z useState:
// PRZED - lokalny stan z useState
function Counter() {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  
  return <div>{count}</div>;
}

// PO - globalny stan z Zustand
// stores/counterStore.ts
export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}));

// W komponencie
function Counter() {
  const { count, increment, decrement } = useCounterStore();
  return <div>{count}</div>;
}

// Możesz używać tego samego count w INNYM komponencie!
function CounterDisplay() {
  const count = useCounterStore(state => state.count);
  return <div>Count: {count}</div>;
}

Oba komponenty widzą TĘ SAMĄ wartość! Zmiana w jednym → aktualizacja w drugim! 🔗

Użycie w komponencie

function Counter() {
  // Subskrybuj cały store
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}
🔍 Co się dzieje krok po kroku:
  1. Komponent wywołuje useCounterStore()
  2. Zustand "subskrybuje" komponent do store (zapisuje: "hej, ten komponent używa countera!")
  3. Destrukturyzujesz potrzebne wartości: { count, increment, ... }
  4. Wyświetlasz count w JSX
  5. Gdy klikasz przycisk → wywołuje się increment()
  6. increment() używa set() żeby zmienić count
  7. Zustand widzi zmianę i mówi: "ten komponent używa count, muszę go przerysować!"
  8. Komponent renderuje się ponownie z nową wartością ✨

Selektory - wybieranie konkretnych danych

// ✅ Subskrybuj tylko count (bardziej wydajne)
function CountDisplay() {
  const count = useCounterStore((state) => state.count);
  
  return <p>Count: {count}</p>;
}

// ✅ Subskrybuj tylko funkcje (nie re-renderuje gdy count się zmienia!)
function CounterButtons() {
  const { increment, decrement } = useCounterStore((state) => ({
    increment: state.increment,
    decrement: state.decrement
  }));
  
  return (
    <>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </>
  );
}
🔍 Selektory - klucz do wydajności!

Bez selektora:

const { count, increment, decrement } = useCounterStore();
// Ten komponent re-renderuje się gdy COKOLWIEK się zmieni w store

Z selektorem:

const count = useCounterStore(state => state.count);
// Ten komponent re-renderuje się TYLKO gdy count się zmieni

Analogia: To jak subskrypcja newslettera:

  • Bez selektora - dostajesz WSZYSTKIE maile (sport, polityka, pogoda...)
  • Z selektorem - dostajesz TYLKO maile o sporcie

W dużych aplikacjach = ogromna różnica w wydajności! 🚀

🌟 Praktyczny przykład: Aplikacja notatek
// stores/notesStore.ts
interface Note {
  id: number;
  title: string;
  content: string;
}

interface NotesStore {
  notes: Note[];
  addNote: (title: string, content: string) => void;
  deleteNote: (id: number) => void;
}

export const useNotesStore = create<NotesStore>((set) => ({
  notes: [],
  
  addNote: (title, content) => set((state) => ({
    notes: [...state.notes, { id: Date.now(), title, content }]
  })),
  
  deleteNote: (id) => set((state) => ({
    notes: state.notes.filter(note => note.id !== id)
  }))
}));

// Komponent 1 - Lista tytułów (re-renderuje gdy notes się zmieni)
function NotesList() {
  const notes = useNotesStore(state => state.notes);
  return (
    <ul>
      {notes.map(note => <li key={note.id}>{note.title}</li>)}
    </ul>
  );
}

// Komponent 2 - Licznik notatek (re-renderuje gdy notes się zmieni)
function NotesCount() {
  const count = useNotesStore(state => state.notes.length);
  return <div>Masz {count} notatek</div>;
}

// Komponent 3 - Przyciski (NIE re-renderuje gdy notes się zmieni!)
function AddNoteButton() {
  const addNote = useNotesStore(state => state.addNote);
  
  return (
    <button onClick={() => addNote('Nowa notatka', 'Treść...')}>
      Dodaj notatkę
    </button>
  );
}

Magia: AddNoteButton ma funkcję addNote, która dodaje notatkę. Gdy dodasz notatkę:

  • ✅ NotesList się przerysowuje (musi pokazać nową notatkę)
  • ✅ NotesCount się przerysowuje (liczba się zmieniła)
  • ❌ AddNoteButton się NIE przerysowuje (funkcja addNote się nie zmieniła!)

To jest wydajność! 💪

💪 Ćwiczenie 2: Twój pierwszy Zustand store

Stwórz store dla prostego licznika odwiedzin strony:

  1. Wartość początkowa: visits: 0
  2. Funkcja incrementVisits() - zwiększa o 1
  3. Funkcja resetVisits() - zeruje
  4. Stwórz komponent który wyświetla liczbę odwiedzin
  5. Stwórz przycisk "Odwiedź ponownie" który zwiększa licznik
  6. Stwórz przycisk "Reset" który zeruje licznik

Bonus: Użyj selektora w komponencie z przyciskami, żeby nie przerysowywał się przy zmianie licznika!

Zaawansowane wzorce

Async actions

🔍 Co to są async actions?

Async actions to funkcje które robią coś co trwa (np. pobieranie danych z API, zapisywanie do bazy). "Async" = asynchroniczny = dzieje się w tle, nie od razu.

Przykłady z życia:

  • Logowanie użytkownika - wysyłasz email/hasło na serwer, czekasz na odpowiedź
  • Ładowanie produktów - pobierasz listę z API
  • Zapisywanie ustawień - wysyłasz na backend

W Zustand możesz używać async/await normalnie w akcjach!

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

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

  fetchUser: async (id) => {
    set({ loading: true, error: null });
    
    try {
      const response = await fetch(`/api/users/${id}`);
      const user = await response.json();
      set({ user, loading: false });
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : 'Unknown error',
        loading: false 
      });
    }
  },

  logout: () => set({ user: null })
}));
🔍 Async action krok po kroku:
  1. Start: set({ loading: true, error: null }) - informujesz UI "ładuję..."
  2. Próba: await fetch(...) - wysyłasz zapytanie do API (czekasz na odpowiedź)
  3. Sukces: set({ user, loading: false }) - zapisujesz dane, wyłączasz "ładuję"
  4. Błąd: set({ error: ..., loading: false }) - zapisujesz błąd, wyłączasz "ładuję"

Dlaczego to ważne? UI wie w każdym momencie co się dzieje: pokazuje spinner gdy loading=true, pokazuje błąd gdy error!=null, pokazuje dane gdy user!=null.

🌟 Przykład: Logowanie użytkownika
interface AuthStore {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  token: null,
  isLoading: false,
  error: null,

  login: async (email, password) => {
    // 1. Start ładowania
    set({ isLoading: true, error: null });
    
    try {
      // 2. Wysłanie danych logowania
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });

      if (!response.ok) {
        throw new Error('Nieprawidłowy email lub hasło');
      }

      // 3. Sukces - zapisz dane
      const data = await response.json();
      set({ 
        user: data.user, 
        token: data.token, 
        isLoading: false 
      });
      
    } catch (error) {
      // 4. Błąd - pokaż komunikat
      set({ 
        error: error instanceof Error ? error.message : 'Błąd logowania',
        isLoading: false 
      });
    }
  },

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

// W komponencie
function LoginForm() {
  const { login, isLoading, error } = useAuthStore();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await login(email, password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      
      {/* Pokaż odpowiedni komunikat w zależności od stanu */}
      {error && <p style={{color: 'red'}}>{error}</p>}
      
      <button disabled={isLoading}>
        {isLoading ? 'Logowanie...' : 'Zaloguj się'}
      </button>
    </form>
  );
}

Przycisk jest zablokowany gdy isLoading=true, a tekst się zmienia! UX jak w prawdziwych aplikacjach! ✨

Nested updates (Immer)

Zustand ma wbudowany Immer dla mutacji immutable:

🔍 Co to jest Immer i po co?

Normalnie w React musisz tworzyć KOPIE obiektów/tablic przy aktualizacji:

// ❌ Źle - mutacja bezpośrednia (React nie widzi zmiany!)
state.todos.push(newTodo);

// ✅ Dobrze - kopia tablicy
setState({ todos: [...state.todos, newTodo] });

Problem: Dla zagnieżdżonych struktur to męczące:

// Zmień completed w konkretnym todo
setState({
  todos: state.todos.map(todo => 
    todo.id === id 
      ? { ...todo, completed: !todo.completed }
      : todo
  )
});

Z Immer: Piszesz jakby to była mutacja, ale Immer automatycznie robi kopię!

// Wygląda jak mutacja, ale to bezpieczna kopia!
set((state) => {
  const todo = state.todos.find(t => t.id === id);
  if (todo) {
    todo.completed = !todo.completed;
  }
});

Krótszy, czytelniejszy kod! 🎉

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

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

interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
}

export const useTodoStore = create<TodoStore>()(
  immer((set) => ({
    todos: [],

    addTodo: (text) => set((state) => {
      // Z Immer możesz mutować bezpośrednio!
      state.todos.push({
        id: Date.now(),
        text,
        completed: false
      });
    }),

    toggleTodo: (id) => set((state) => {
      const todo = state.todos.find(t => t.id === id);
      if (todo) {
        todo.completed = !todo.completed;
      }
    }),

    deleteTodo: (id) => set((state) => {
      state.todos = state.todos.filter(t => t.id !== id);
    })
  }))
);
💪 Ćwiczenie 3: Store z async akcją

Stwórz store dla listy postów z bloga:

  1. Stan: posts: Post[], loading: boolean, error: string | null
  2. Akcja fetchPosts() która:
    • Ustawia loading=true
    • Pobiera dane z API: https://jsonplaceholder.typicode.com/posts
    • Przy sukcesie: zapisuje posty, loading=false
    • Przy błędzie: zapisuje error, loading=false
  3. Stwórz komponent który pokazuje listę postów lub spinner gdy loading=true

Persist middleware (localStorage)

🔍 Co to jest persist i po co?

Persist = zapisuje stan do localStorage. Co to daje?

  • Odświeżenie strony → dane są zachowane! 🎉
  • Zamknięcie zakładki i otwarcie ponownie → dane są zachowane!
  • Idealne dla: motywu (jasny/ciemny), języka, ustawień, koszyka zakupów, preferencji użytkownika

Jak to działa? Zustand automatycznie:

  1. Przy każdej zmianie stanu → zapisuje do localStorage
  2. Przy starcie aplikacji → wczytuje z localStorage
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface SettingsStore {
  theme: 'light' | 'dark';
  language: string;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (lang: string) => void;
}

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'pl',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language })
    }),
    {
      name: 'settings-storage', // Klucz w localStorage
      storage: createJSONStorage(() => localStorage),
    }
  )
);

Odśwież stronę – theme i language są zachowane!

🌟 Przykład: Przełącznik motywu z persist
// stores/themeStore.ts
interface ThemeStore {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

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

// W komponencie
function ThemeToggle() {
  const { theme, toggleTheme } = useThemeStore();
  
  // Zastosuj motyw do body
  useEffect(() => {
    document.body.className = theme;
  }, [theme]);

  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '🌙 Ciemny' : '☀️ Jasny'}
    </button>
  );
}

// CSS
// body.light { background: white; color: black; }
// body.dark { background: #222; color: white; }

Zmień motyw, odśwież stronę - motyw jest zachowany! Tak działają prawdziwe aplikacje jak YouTube czy Twitter! 🌓

DevTools

🔍 Co to są DevTools?

Redux DevTools to rozszerzenie do Chrome/Firefox które pozwala:

  • Zobaczyć wszystkie akcje (increment, decrement, login...)
  • Zobaczyć jak zmienia się stan po każdej akcji
  • "Time travel" - cofanie się do poprzednich stanów
  • Eksportowanie/importowanie stanu

To jak machina czasu dla aplikacji - super przydatne przy debugowaniu! 🔍

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'),
      decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
    }),
    { name: 'CounterStore' }
  )
);

Otwórz Redux DevTools – zobaczysz wszystkie akcje!

🔍 Parametry set() z devtools:
set(
  (state) => ({ count: state.count + 1 }),  // 1. Nowy stan
  false,                                      // 2. Replace? (prawie zawsze false)
  'increment'                                 // 3. Nazwa akcji (dla DevTools)
)

Trzeci parametr to nazwa akcji która pojawi się w DevTools. Dzięki temu wiesz które akcje były wywołane!

Kombinacja middleware

🔍 Łączenie middleware - kolejność ma znaczenie!

Możesz łączyć middleware (Immer + persist + devtools). Kolejność: od wewnątrz na zewnątrz:

create()(
  devtools(      // 3. Na końcu - DevTools widzi wszystko
    persist(     // 2. Środek - persist zapisuje do localStorage
      immer(     // 1. Wewnątrz - Immer pozwala na mutacje
        (set) => ({ ... })
      )
    )
  )
)

Czytaj od środka: Immer → Persist → DevTools

export const useAuthStore = create<AuthStore>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        token: null,
        login: async (email, password) => {
          // ...
        },
        logout: () => set((state) => {
          state.user = null;
          state.token = null;
        })
      })),
      { name: 'auth-storage' }
    ),
    { name: 'AuthStore' }
  )
);
🌟 Przykład: Pełny auth store z wszystkimi middleware
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

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

interface AuthStore {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        token: null,
        isLoading: false,
        error: null,

        login: async (email, password) => {
          set((state) => {
            state.isLoading = true;
            state.error = null;
          });

          try {
            const response = await fetch('/api/login', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ email, password })
            });

            const data = await response.json();
            
            set((state) => {
              state.user = data.user;
              state.token = data.token;
              state.isLoading = false;
            });
          } catch (error) {
            set((state) => {
              state.error = 'Błąd logowania';
              state.isLoading = false;
            });
          }
        },

        logout: () => set((state) => {
          state.user = null;
          state.token = null;
        })
      })),
      { 
        name: 'auth-storage',
        // Nie zapisuj loading i error do localStorage
        partialState: (state) => ({
          user: state.user,
          token: state.token
        })
      }
    ),
    { name: 'AuthStore' }
  )
);

// Użycie w komponencie
function UserProfile() {
  const user = useAuthStore(state => state.user);
  const logout = useAuthStore(state => state.logout);

  if (!user) {
    return <div>Nie zalogowany</div>;
  }

  return (
    <div>
      <h2>Witaj, {user.name}!</h2>
      <p>{user.email}</p>
      <button onClick={logout}>Wyloguj</button>
    </div>
  );
}

Ten store ma WSZYSTKO:

  • Immer - proste mutacje stanu
  • Persist - user i token zachowane po odświeżeniu
  • DevTools - debugowanie akcji login/logout
  • Async - obsługa API, loading, błędy
💪 Ćwiczenie 4: Store z middleware

Stwórz store dla ustawień aplikacji:

  1. Stan: fontSize: number, theme: 'light' | 'dark', notifications: boolean
  2. Akcje: setFontSize, toggleTheme, toggleNotifications
  3. Użyj persist żeby zapisywać do localStorage
  4. Użyj devtools żeby debugować akcje
  5. Stwórz panel ustawień gdzie użytkownik może to wszystko zmieniać
  6. Odśwież stronę - ustawienia powinny być zachowane!
Slice pattern (organizacja store)

Dla dużych store dziel na części:

🔍 Co to jest Slice Pattern?

Wyobraź sobie wielki store z 50 polami i 30 akcjami. To chaos! 😵

Slice Pattern = podziel store na "plasterki" (slices). Każdy slice odpowiada za swoją część:

  • userSlice - dane użytkownika (login, profile, preferences)
  • todoSlice - lista todo (add, toggle, delete)
  • cartSlice - koszyk (addItem, removeItem, updateQuantity)

Potem łączysz je w jeden store! Każdy slice w osobnym pliku = łatwy w utrzymaniu kod! 📁

// slices/userSlice.ts
export interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
  clearUser: () => void;
}

export const createUserSlice: StateCreator<UserSlice> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null })
});

// slices/todoSlice.ts
export interface TodoSlice {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
}

export const createTodoSlice: StateCreator<TodoSlice> = (set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, completed: false }]
  })),
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
  }))
});

// store.ts
type AppStore = UserSlice & TodoSlice;

export const useAppStore = create<AppStore>()((...args) => ({
  ...createUserSlice(...args),
  ...createTodoSlice(...args)
}));
🔍 Jak to działa?
  1. createUserSlice - funkcja zwracająca część stanu (user + akcje)
  2. createTodoSlice - funkcja zwracająca część stanu (todos + akcje)
  3. AppStore = UserSlice & TodoSlice - łączymy typy (TypeScript wie o wszystkim)
  4. {...createUserSlice(...args), ...createTodoSlice(...args)} - łączymy slices w jeden store

W komponencie używasz jak zwykle:

const user = useAppStore(state => state.user);
const todos = useAppStore(state => state.todos);
const addTodo = useAppStore(state => state.addTodo);
🌟 Przykład: Aplikacja e-commerce z 3 slices
// slices/authSlice.ts
export interface AuthSlice {
  user: User | null;
  token: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
  user: null,
  token: null,
  login: async (email, password) => {
    // ... logika logowania
  },
  logout: () => set({ user: null, token: null })
});

// slices/cartSlice.ts
export interface CartSlice {
  items: CartItem[];
  addItem: (product: Product) => void;
  removeItem: (id: number) => void;
  clearCart: () => void;
}

export const createCartSlice: StateCreator<CartSlice> = (set) => ({
  items: [],
  addItem: (product) => set((state) => ({
    items: [...state.items, { ...product, quantity: 1 }]
  })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(item => item.id !== id)
  })),
  clearCart: () => set({ items: [] })
});

// slices/productsSlice.ts
export interface ProductsSlice {
  products: Product[];
  loading: boolean;
  fetchProducts: () => Promise<void>;
}

export const createProductsSlice: StateCreator<ProductsSlice> = (set) => ({
  products: [],
  loading: false,
  fetchProducts: async () => {
    set({ loading: true });
    const response = await fetch('/api/products');
    const products = await response.json();
    set({ products, loading: false });
  }
});

// store.ts - łączenie wszystkich slices
type AppStore = AuthSlice & CartSlice & ProductsSlice;

export const useAppStore = create<AppStore>()(
  devtools(
    persist(
      (...args) => ({
        ...createAuthSlice(...args),
        ...createCartSlice(...args),
        ...createProductsSlice(...args)
      }),
      { 
        name: 'ecommerce-storage',
        partialState: (state) => ({
          user: state.user,
          token: state.token,
          items: state.items
        })
      }
    ),
    { name: 'EcommerceStore' }
  )
);

// Użycie w komponentach
function Header() {
  const user = useAppStore(state => state.user);
  const itemCount = useAppStore(state => state.items.length);
  
  return (
    <header>
      <div>Witaj, {user?.name || 'Gość'}!</div>
      <div>🛒 ({itemCount})</div>
    </header>
  );
}

Zalety Slice Pattern:

  • ✅ Kod podzielony na logiczne części (łatwy w utrzymaniu)
  • ✅ Każdy slice w osobnym pliku (nie przewijasz 1000 linii)
  • ✅ Łatwo dodać nowy slice (np. notificationsSlice)
  • ✅ Łatwo testować (testujesz każdy slice osobno)
Praktyczny przykład: Shopping Cart

Kompletny store dla koszyka zakupowego:

🔍 Shopping Cart - czemu to jest świetny przykład?

Koszyk zakupowy to klasyczny przykład globalnego stanu:

  • Wiele komponentów używa tych samych danych - lista produktów, badge z liczbą, podsumowanie, kasa
  • Musi być zachowany - użytkownik nie chce stracić koszyka po odświeżeniu strony
  • Złożona logika - dodawanie, usuwanie, aktualizacja ilości, obliczanie sumy

Zobaczysz tutaj wszystko czego się nauczyłeś: Immer + persist + devtools + computed getters!

// stores/cartStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

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

interface CartItem extends Product {
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (product: Product, quantity?: number) => void;
  removeItem: (productId: number) => void;
  updateQuantity: (productId: number, quantity: number) => void;
  clearCart: () => void;
  
  // Computed values
  total: number;
  itemCount: number;
}

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

        addItem: (product, quantity = 1) => set((state) => {
          const existing = state.items.find(item => item.id === product.id);
          
          if (existing) {
            existing.quantity += quantity;
          } else {
            state.items.push({ ...product, quantity });
          }
        }),

        removeItem: (productId) => set((state) => {
          state.items = state.items.filter(item => item.id !== productId);
        }),

        updateQuantity: (productId, quantity) => set((state) => {
          if (quantity <= 0) {
            state.items = state.items.filter(item => item.id !== productId);
            return;
          }

          const item = state.items.find(item => item.id === productId);
          if (item) {
            item.quantity = quantity;
          }
        }),

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

        // Computed getters
        get total() {
          return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0);
        },

        get itemCount() {
          return get().items.reduce((sum, item) => sum + item.quantity, 0);
        }
      })),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);
🔍 Computed getters - co to?

get total() i get itemCount() to computed getters - wartości obliczane "na żywo":

// Za każdym razem gdy czytasz total, oblicza się od nowa
const total = useCartStore(state => state.total);

// To oblicza: suma wszystkich (cena * ilość)
items.reduce((sum, item) => sum + item.price * item.quantity, 0)

Zalety computed getters:

  • ✅ Nie musisz aktualizować ręcznie (jak dodasz produkt, total się zaktualizuje sam!)
  • ✅ Brak duplikacji danych (nie przechowujesz total osobno)
  • ✅ Zawsze aktualne (niemożliwe żeby były przestarzałe)

Dlaczego używamy get()? Bo jesteśmy w getterze, nie w akcji. get() pobiera aktualny stan.

Użycie:

// ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
  const addItem = useCartStore((state) => state.addItem);

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price} PLN</p>
      <button onClick={() => addItem(product)}>
        Dodaj do koszyka
      </button>
    </div>
  );
}

// CartWidget.tsx
function CartWidget() {
  const { itemCount, total } = useCartStore((state) => ({
    itemCount: state.itemCount,
    total: state.total
  }));

  return (
    <div className="cart-widget">
      <span>🛒 {itemCount}</span>
      <span>{total.toFixed(2)} PLN</span>
    </div>
  );
}

// Cart.tsx
function Cart() {
  const { items, updateQuantity, removeItem, clearCart, total } = useCartStore();

  if (items.length === 0) {
    return <div>Koszyk pusty</div>;
  }

  return (
    <div className="cart">
      <h2>Twój koszyk</h2>
      {items.map(item => (
        <div key={item.id} className="cart-item">
          <img src={item.image} alt={item.name} />
          <span>{item.name}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
            min="1"
          />
          <span>{(item.price * item.quantity).toFixed(2)} PLN</span>
          <button onClick={() => removeItem(item.id)}>Usuń</button>
        </div>
      ))}
      
      <div className="cart-summary">
        <p>Suma: <strong>{total.toFixed(2)} PLN</strong></p>
        <button onClick={clearCart}>Wyczyść koszyk</button>
        <button className="checkout">Przejdź do płatności</button>
      </div>
    </div>
  );
}
🌟 Flow dodawania produktu do koszyka:
  1. Użytkownik klika "Dodaj do koszyka" na ProductCard
  2. Wywołuje się addItem(product)
  3. Store sprawdza: czy produkt już jest w koszyku?
    • Jeśli TAK → zwiększ quantity (np. 1 → 2)
    • Jeśli NIE → dodaj nowy item z quantity: 1
  4. Persist zapisuje do localStorage (automatycznie!)
  5. Komponenty używające koszyka się przerysowują:
    • CartWidget - pokazuje nową liczbę i sumę
    • Cart (jeśli otwarty) - pokazuje nowy produkt
  6. DevTools loguje akcję "addItem" (dla debugowania)

To jak dobrze naoliwiona maszyna - wszystko działa automatycznie! ⚙️

💪 Ćwiczenie 5: Rozszerz Shopping Cart

Dodaj nowe funkcje do koszyka:

  1. Kupony rabatowe:
    • Stan: couponCode: string | null, discount: number
    • Akcja: applyCoupon(code: string) - sprawdza kod (np. "SAVE10" = 10% rabatu)
    • Computed getter: totalWithDiscount - oblicza cenę po rabacie
  2. Ulubione produkty:
    • Stan: favorites: number[] (ID produktów)
    • Akcje: addToFavorites(id), removeFromFavorites(id)
    • Persist w localStorage
  3. Historia zamówień:
    • Stan: orders: Order[]
    • Akcja: checkout() - przenosi items do orders, czyści koszyk
    • Pokazuje listę poprzednich zamówień
Zustand vs Context vs Redux

🔍 Która biblioteka kiedy?

To najczęstsze pytanie początkujących: "Zustand, Context czy Redux?" Odpowiedź zależy od projektu:

Aspekt Zustand Context API Redux
Setup ✅ Prosty (1 funkcja) ✅ Prosty ❌ Złożony (wiele plików)
Boilerplate ✅ Minimalny ✅ Średni ❌ Duży
TypeScript ✅ Świetny ✅ Dobry ✅ Świetny
DevTools ✅ Tak ❌ Nie ✅ Tak
Performance ✅ Świetny ⚠️ Może być problem ✅ Świetny
Learning curve ✅ Łatwy ✅ Łatwy ❌ Trudny
Middleware ✅ Tak ❌ Nie ✅ Tak
Async ✅ Proste ⚠️ Manual ⚠️ Thunks/Saga
Bundle size ✅ 1KB ✅ 0KB (built-in) ❌ 5KB+
🔍 Wyjaśnienie wierszy tabeli:
  • Setup - ile kodu trzeba napisać żeby zacząć?
  • Boilerplate - ile powtarzalnego, nudnego kodu?
  • Performance - czy szybkie? Czy nie ma niepotrzebnych re-renderów?
  • Learning curve - jak trudno się nauczyć?
  • Middleware - czy można dodać persist, devtools, logger itp?
  • Async - jak łatwo robić rzeczy asynchroniczne (API calls)?
  • Bundle size - ile waży biblioteka? (mniejsze = szybsze ładowanie strony)
Moja rekomendacja:
  • Małe/średnie projekty: Zustand
  • Potrzebujesz tylko theme/auth: Context API
  • Duży enterprise projekt z złożonym state: Redux Toolkit
  • Chcesz prostoty i wydajności: Zustand
🌟 Przykłady projektów i wybór biblioteki:

Projekt 1: Blog osobisty

  • Stan: motyw (jasny/ciemny), język (PL/EN)
  • Wybór: Context API - prosty stan, niewiele zmian

Projekt 2: Sklep internetowy (10 produktów)

  • Stan: koszyk, user, produkty, filtry
  • Wybór: Zustand - średnia złożoność, persist potrzebny dla koszyka

Projekt 3: Panel administracyjny (50+ tabel, 20+ formularzy)

  • Stan: users, products, orders, analytics, permissions, notifications...
  • Wybór: Redux Toolkit - bardzo złożony stan, dużo developerów pracuje razem

Projekt 4: Aplikacja TODO

  • Stan: lista todo, filtry, ustawienia
  • Wybór: Zustand - idealna równowaga prostoty i mocy

Reguła kciuka:

  • 1-2 rzeczy w stanie? → Context API
  • 3-10 rzeczy w stanie? → Zustand 🏆
  • 10+ rzeczy, duży zespół? → Redux Toolkit
Best Practices

1. Jeden store vs wiele stores

🔍 Dylematy architektury: ile stores?

Wiele małych stores (POLECAM!):

  • ✅ Łatwiejsze w utrzymaniu - każdy store robi jedną rzecz
  • ✅ Łatwiejsze w testowaniu - testujesz osobno
  • ✅ Lepsze TypeScript - mniejsze interfejsy
  • ✅ Można używać persist dla niektórych, dla innych nie

Jeden wielki store:

  • ⚠️ Trudniejszy w utrzymaniu - 500 linii w jednym pliku
  • ⚠️ Trudniejszy w testowaniu - musisz testować wszystko razem
  • ✅ Prostsze importy - jeden import zamiast 5
// ✅ Wiele małych stores (polecam!)
const useAuthStore = create(/* ... */);
const useCartStore = create(/* ... */);
const useSettingsStore = create(/* ... */);

// ⚠️ Jeden wielki store (może być OK)
const useAppStore = create(/* wszystko w jednym */);

2. Selektory jako funkcje

🔍 Dlaczego selektory jako osobne funkcje?

Zamiast pisać selector w komponencie:

// ❌ Powtarzalny kod w każdym komponencie
const total = useCartStore(state => state.total);

Wydziel jako funkcję:

// ✅ W pliku store
export const selectCartTotal = (state: CartStore) => state.total;

// W komponencie
const total = useCartStore(selectCartTotal);

Zalety: Reużywalny kod, łatwe testowanie, refaktoryzacja w jednym miejscu!

// stores/cartStore.ts
export const selectCartTotal = (state: CartStore) => state.total;
export const selectItemCount = (state: CartStore) => state.itemCount;

// W komponencie
const total = useCartStore(selectCartTotal);
const itemCount = useCartStore(selectItemCount);

3. Nie przechowuj derived state

🔍 Derived state - co to?

Derived state = dane które można obliczyć z innych danych.

Przykład ZŁY:

interface TodoStore {
  todos: Todo[];
  completedCount: number; // ❌ To można obliczyć!
}

// Musisz pamiętać o aktualizowaniu completedCount
addTodo: (text) => set((state) => ({
  todos: [...state.todos, newTodo],
  completedCount: state.completedCount // Ups, trzeba przeliczyć!
}));

Przykład DOBRY:

interface TodoStore {
  todos: Todo[];
  get completedCount() { // ✅ Zawsze aktualne!
    return this.todos.filter(t => t.completed).length;
  }
}

Nie musisz pamiętać o aktualizowaniu - liczy się samo! 🎯

4. Actions w osobnym namespace (opcjonalnie)

🔍 Grupowanie akcji w namespace

To wzorzec dla bardzo dużych stores. Oddziela dane od akcji:

// Bez namespace
const { todos, addTodo, toggleTodo } = useTodoStore();

// Z namespace
const { todos } = useTodoStore();
const { addTodo, toggleTodo } = useTodoStore(state => state.actions);

Kiedy używać? Gdy masz 20+ akcji w store. Dla małych stores - zbędne!

5. TypeScript strict mode

🔍 Dlaczego typować store?

Bez TypeScript: Możesz napisać state.cout (literówka!) i dopiero w przeglądarce zobaczysz błąd.

Z TypeScript: VS Code podkreśla błąd od razu - nie możesz uruchomić aplikacji z błędem!

Zawsze typuj store! To 30 sekund więcej pisania, ale oszczędzisz godziny debugowania! 🐛

// ✅ Zawsze typuj store
interface MyStore {
  value: string;
  setValue: (val: string) => void;
}

export const useMyStore = create<MyStore>()((set) => ({
  value: '',
  setValue: (val) => set({ value: val })
}));
💪 Ćwiczenie 6: Refaktoryzacja store

Masz poniższy "zły" store. Popraw go zgodnie z Best Practices:

// ❌ Zły kod
export const useBadStore = create((set, get) => ({
  todos: [],
  completedCount: 0, // Derived state!
  activeCount: 0,    // Derived state!
  
  addTodo: (text) => set((state) => {
    const newTodos = [...state.todos, { id: Date.now(), text, completed: false }];
    return {
      todos: newTodos,
      activeCount: state.activeCount + 1 // Ręczna aktualizacja!
    };
  }),
  
  // ... więcej akcji
}));

Popraw na:

  1. Dodaj TypeScript interface
  2. Usuń derived state (completedCount, activeCount)
  3. Dodaj computed getters
  4. Wyeksportuj selektory jako funkcje
  5. Dodaj persist i devtools middleware
Testowanie Zustand stores

🔍 Dlaczego testować stores?

Store to logika Twojej aplikacji. Jeśli coś zepsuje się w store, cała aplikacja nie działa! Testy dają pewność że:

  • ✅ Akcje robią to co powinny
  • ✅ Stan się poprawnie aktualizuje
  • ✅ Computed getters zwracają poprawne wartości
  • ✅ Po refaktoryzacji wszystko nadal działa
// stores/counterStore.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from './counterStore';

describe('CounterStore', () => {
  beforeEach(() => {
    // Reset store przed każdym testem
    useCounterStore.setState({ count: 0 });
  });

  test('initial count is 0', () => {
    const { result } = renderHook(() => useCounterStore());
    expect(result.current.count).toBe(0);
  });

  test('increment increases count', () => {
    const { result } = renderHook(() => useCounterStore());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  test('decrement decreases count', () => {
    const { result } = renderHook(() => useCounterStore());
    
    act(() => {
      result.current.increment();
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(0);
  });

  test('reset sets count to 0', () => {
    const { result } = renderHook(() => useCounterStore());
    
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(0);
  });
});
🔍 Jak czytać testy?
  1. beforeEach - wykonuje się PRZED każdym testem (tutaj: resetuje store do count=0)
  2. renderHook - renderuje hook w testowym środowisku (jakby był w komponencie)
  3. act - wywołuje akcje (increment, decrement...) i czeka aż zaktualizuje się stan
  4. expect - sprawdza czy wartość jest taka jak oczekiwana

Przykład czytania testu:

test('increment increases count', () => {
  // 1. Renderuj hook (zacznij z count=0)
  const { result } = renderHook(() => useCounterStore());
  
  // 2. Wywołaj akcję increment
  act(() => {
    result.current.increment();
  });
  
  // 3. Sprawdź czy count wzrósł do 1
  expect(result.current.count).toBe(1);
});
🌟 Przykład: Test Shopping Cart store
import { renderHook, act } from '@testing-library/react';
import { useCartStore } from './cartStore';

describe('CartStore', () => {
  const mockProduct = {
    id: 1,
    name: 'Test Product',
    price: 100,
    image: 'test.jpg'
  };

  beforeEach(() => {
    useCartStore.setState({ items: [] });
  });

  test('adds item to cart', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].id).toBe(1);
    expect(result.current.items[0].quantity).toBe(1);
  });

  test('increases quantity when adding existing item', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem(mockProduct); // Dodaj ponownie
    });
    
    expect(result.current.items).toHaveLength(1); // Nadal 1 item
    expect(result.current.items[0].quantity).toBe(2); // Ale ilość = 2
  });

  test('calculates total correctly', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem(mockProduct, 2); // 2x 100 = 200
    });
    
    expect(result.current.total).toBe(200);
  });

  test('removes item from cart', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.removeItem(1);
    });
    
    expect(result.current.items).toHaveLength(0);
  });

  test('clears entire cart', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem({ ...mockProduct, id: 2 });
      result.current.clearCart();
    });
    
    expect(result.current.items).toHaveLength(0);
  });
});

Te testy pokrywają wszystkie kluczowe przypadki - dodawanie, usuwanie, ilość, suma. Jeśli wszystkie przechodzą = store działa! ✅

Migracja z Context API

🔍 Kiedy migrować z Context do Zustand?

Sygnały że czas na migrację:

  • ⚠️ Zauważasz wolne działanie (niepotrzebne re-rendery)
  • ⚠️ Masz 3+ Contextów owijających aplikację
  • ⚠️ Kod Context Providera ma 100+ linii
  • ⚠️ Ciężko debugować co się zmienia i kiedy
  • ⚠️ Chcesz persist do localStorage
// ❌ Przed - Context API
const CartContext = createContext<CartContextType | undefined>(undefined);

export function CartProvider({ children }: { children: ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
  
  const addItem = (product: Product) => {
    setItems([...items, { ...product, quantity: 1 }]);
  };

  return (
    <CartContext.Provider value={{ items, addItem }}>
      {children}
    </CartContext.Provider>
  );
}

// ✅ Po - Zustand
export const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (product) => set((state) => ({
    items: [...state.items, { ...product, quantity: 1 }]
  }))
}));

// Nie potrzebujesz Provider!
🌟 Krok po kroku: Migracja Auth Context → Zustand

Krok 1: Stary kod z Context

// contexts/AuthContext.tsx
const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  const login = async (email, password) => {
    setLoading(true);
    // ... logika logowania
    setLoading(false);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login }}>
      {children}
    </AuthContext.Provider>
  );
}

// W App.tsx
<AuthProvider>
  <App />
</AuthProvider>

Krok 2: Nowy kod z Zustand

// stores/authStore.ts
export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  loading: false,
  
  login: async (email, password) => {
    set({ loading: true });
    // ... logika logowania
    set({ loading: false });
  }
}));

// W App.tsx - nic nie trzeba owijać! ✨

Krok 3: Zamiana w komponentach

// Przed
function Header() {
  const { user, login } = useContext(AuthContext);
  // ...
}

// Po
function Header() {
  const { user, login } = useAuthStore();
  // ...
}

Zalety po migracji:

  • ✅ Mniej kodu (brak Provider, brak Context)
  • ✅ Łatwo dodać persist - user pozostaje zalogowany po odświeżeniu
  • ✅ DevTools - widzisz akcje login/logout
  • ✅ Wydajność - tylko komponenty używające user się przerysowują
Podsumowanie

To był wpis pełen praktycznej wiedzy! Nauczyliśmy się:

  • Dlaczego Zustand – prostszy od Redux, wydajniejszy od Context
  • Podstawycreate, set, get, selektory
  • Zaawansowane wzorceasync actions, Immer, persist, devtools
  • Slice pattern – organizacja dużych stores
  • Shopping Cart – kompletny, produkcyjny przykład
  • PorównanieZustand vs Context vs Redux
  • Best Practices – 5 złotych zasad
  • Testowanie – jak testować stores
  • Migracja – z Context API do Zustand

Zustand to moja ulubiona biblioteka state management – prosta, wydajna, przyjemna w użyciu. Idealna dla większości projektów React!

W kolejnym wpisie poznamy testowanie aplikacji ReactTesting Library, Jest, testy komponentów, hooków, integracyjne!

Zadanie dla Ciebie

Stwórz Zustand store dla aplikacji Todo:

  1. CRUD operacje (add, toggle, delete, edit)
  2. Filtry (all, active, completed)
  3. Persist w localStorage
  4. DevTools integration
  5. TypeScript type safety
  6. (Bonus) Napisz testy dla store
🎯 BONUS: Wielki projekt końcowy - Aplikacja Notatek

Stwórz kompletną aplikację do notatek używając wszystkiego czego się nauczyłeś:

Features:

  • Notatki: tytuł, treść, kategoria, tagi, data utworzenia
  • Foldery: organizacja notatek w foldery (zagnieżdżone)
  • Wyszukiwanie: szukaj po tytule, treści, tagach
  • Filtry: według kategorii, folderów, dat
  • Sortowanie: alfabetycznie, według daty, według ważności
  • Ulubione: oznaczaj ważne notatki gwiazdką
  • Kosz: usunięte notatki trafiają do kosza (można przywrócić)
  • Eksport/Import: pobieraj notatki jako JSON

Stores (Slice Pattern!):

  • notesSlice: CRUD operacji, wyszukiwanie, filtry
  • foldersSlice: tworzenie, edycja, usuwanie folderów
  • settingsSlice: motyw, sortowanie, widok (lista/grid)
  • trashSlice: usunięte notatki, przywracanie, czyszczenie

Wymagania techniczne:

  • TypeScript - wszystkie stores z interfejsami
  • Slice Pattern - każdy slice w osobnym pliku
  • Persist - notatki zachowane w localStorage
  • DevTools - debugowanie akcji
  • Immer - proste aktualizacje zagnieżdżonych struktur
  • Computed getters - liczniki (ile notatek, ile w koszu)
  • Selektory - wydzielone jako funkcje
  • Testy - minimum 10 testów dla głównych akcji

UI Components:

  • Sidebar z folderami i filtrami
  • Lista notatek (grid lub lista)
  • Edytor notatki (textarea z markdown?)
  • Search bar z live search
  • Header z licznikami i przyciskami

To ambitny projekt, ale łączy WSZYSTKO z artykułu o Zustand. Idealna praktyka przed prawdziwymi projektami! 📝✨