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 - 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. 🐌
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):
- Jakie dane są współdzielone między komponentami?
- Które komponenty często się przerysowują?
- Czy niektóre komponenty przerysowują się niepotrzebnie?
- Czy używasz wielu Context providerów? (Ile?)
To pomoże Ci zrozumieć kiedy Zustand będzie przydatny!
Podstawy Zustand
// 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:
- create<CounterState> - tworzy nowy store z typem TypeScript
- (set) => ({ ... }) - funkcja która zwraca początkowy stan + akcje
- count: 0 - wartość początkowa (tak jak w useState(0))
- increment: () => set(...) - akcja (funkcja zmieniająca stan)
- set((state) => ...) - aktualizuje stan na podstawie poprzedniego stanu
- 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! 🔗
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:
- Komponent wywołuje
useCounterStore()
- Zustand "subskrybuje" komponent do store (zapisuje: "hej, ten komponent używa countera!")
- Destrukturyzujesz potrzebne wartości:
{ count, increment, ... }
- Wyświetlasz
count w JSX
- Gdy klikasz przycisk → wywołuje się
increment()
increment() używa set() żeby zmienić count
- Zustand widzi zmianę i mówi: "ten komponent używa count, muszę go przerysować!"
- Komponent renderuje się ponownie z nową wartością ✨
// ✅ 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:
- Wartość początkowa:
visits: 0
- Funkcja
incrementVisits() - zwiększa o 1
- Funkcja
resetVisits() - zeruje
- Stwórz komponent który wyświetla liczbę odwiedzin
- Stwórz przycisk "Odwiedź ponownie" który zwiększa licznik
- 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
🔍 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:
- Start:
set({ loading: true, error: null }) - informujesz UI "ładuję..."
- Próba:
await fetch(...) - wysyłasz zapytanie do API (czekasz na odpowiedź)
- Sukces:
set({ user, loading: false }) - zapisujesz dane, wyłączasz "ładuję"
- 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! ✨
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:
- Stan:
posts: Post[], loading: boolean, error: string | null
-
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
- Stwórz komponent który pokazuje listę postów lub spinner gdy loading=true
🔍 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:
- Przy każdej zmianie stanu → zapisuje do localStorage
- 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! 🌓
🔍 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!
🔍 Łą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:
- Stan:
fontSize: number, theme: 'light' | 'dark', notifications: boolean
- Akcje:
setFontSize, toggleTheme, toggleNotifications
- Użyj persist żeby zapisywać do localStorage
- Użyj devtools żeby debugować akcje
- Stwórz panel ustawień gdzie użytkownik może to wszystko zmieniać
- 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?
- createUserSlice - funkcja zwracająca część stanu (user + akcje)
- createTodoSlice - funkcja zwracająca część stanu (todos + akcje)
- AppStore = UserSlice & TodoSlice - łączymy typy (TypeScript wie o wszystkim)
- {...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:
- Użytkownik klika "Dodaj do koszyka" na ProductCard
- Wywołuje się
addItem(product)
-
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
- Persist zapisuje do localStorage (automatycznie!)
-
Komponenty używające koszyka się przerysowują:
- CartWidget - pokazuje nową liczbę i sumę
- Cart (jeśli otwarty) - pokazuje nowy produkt
- 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:
-
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
-
Ulubione produkty:
- Stan:
favorites: number[] (ID produktów)
- Akcje:
addToFavorites(id), removeFromFavorites(id)
- Persist w localStorage
-
Historia zamówień:
- Stan:
orders: Order[]
- Akcja:
checkout() - przenosi items do orders, czyści koszyk
- Pokazuje listę poprzednich zamówień
Best Practices
🔍 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 */);
🔍 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);
🔍 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! 🎯
🔍 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!
🔍 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:
- Dodaj TypeScript interface
- Usuń derived state (completedCount, activeCount)
- Dodaj computed getters
- Wyeksportuj selektory jako funkcje
- 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?
- beforeEach - wykonuje się PRZED każdym testem (tutaj: resetuje store do count=0)
- renderHook - renderuje hook w testowym środowisku (jakby był w komponencie)
- act - wywołuje akcje (increment, decrement...) i czeka aż zaktualizuje się stan
- 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ą