Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz
2026-02-03
Wprowadzenie
W poprzednich wpisach nauczyliśmy się zarządzać stanem komponentów używając useState . Działa świetnie dla prostych przypadków – licznik, input, toggle. Ale co jeśli masz komponent ze złożonym stanem? Co jeśli jedna akcja użytkownika musi zaktualizować wiele pól state jednocześnie? Co jeśli logika aktualizacji stanu jest skomplikowana i powtarzalna?
Wtedy useState zaczyna być problematyczny. Kod staje się chaotyczny, trudny do debugowania i testowania. Szczęśliwie React ma rozwiązanie: useReducer – Hook, który implementuje pattern znaney z Redux , ale w prostszej, lokalnej formie.
W tym wpisie nauczymy się kiedy useState przestaje wystarczać, poznamy useReducer od podstaw, zrozumiemy koncepty actions , reducers i dispatch , oraz nauczymy się łączyć useReducer z Context dla pełnego state management. To będzie techniczny wpis, ale efekt będzie wart zachodu – Twój kod stanie się czystszy, bardziej przewidywalny i łatwiejszy w utrzymaniu!
Kiedy useState to za mało?
Zacznijmy od zobaczenia problemu.
function UserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
// 8 różnych useState! Trudne w zarządzaniu
}
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [tax, setTax] = useState(0);
const [finalPrice, setFinalPrice] = useState(0);
function addItem(item) {
setItems([...items, item]);
// Teraz musisz zaktualizować wszystkie powiązane wartości!
const newTotal = calculateTotal([...items, item]);
setTotal(newTotal);
const newTax = calculateTax(newTotal);
setTax(newTax);
const newFinalPrice = newTotal - discount + newTax;
setFinalPrice(newFinalPrice);
// To jest nieczytelne i podatne na błędy!
}
}
function Counter() {
const [count, setCount] = useState(0);
const [history, setHistory] = useState([]);
function increment() {
setCount(prev => prev + 1);
setHistory(prev => [...prev, count + 1]); // Złożona logika
}
function decrement() {
setCount(prev => prev - 1);
setHistory(prev => [...prev, count - 1]);
}
function reset() {
setCount(0);
setHistory([]);
}
}
Logika jest rozproszona, powtarzalna i łatwo o błąd.
Rozwiązanie: useReducer
useReducer to Hook, który pozwala zarządzać stanem przez akcje (actions) i reducer – czystą funkcję określającą jak state się zmienia.
const [state, dispatch] = useReducer(reducer, initialState);
state – aktualny stan
dispatch – funkcja do wysyłania akcji
reducer – funkcja określająca jak stan się zmienia
initialState – początkowy stan
import { useReducer } from 'react';
// 1. Definiujemy typy akcji
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' };
// 2. Definiujemy reducer
function counterReducer(state: number, action: Action): number {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
case 'reset':
return 0;
default:
return state;
}
}
// 3. Używamy w komponencie
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
Co się dzieje?
dispatch({ type: 'increment' }) – wysyłamy akcję
React wywołuje counterReducer(currentState, action)
Reducer zwraca nowy stan
React re-renderuje komponent z nowym stanem
Anatomia useReducer
Stan może być dowolnego typu: number, string, object, array.
// Prosty state
const initialState = 0;
// Złożony state
interface State {
count: number;
user: User | null;
isLoading: boolean;
}
const initialState: State = {
count: 0,
user: null,
isLoading: false
};
Akcje to obiekty z type (wymagane) i opcjonalnym payload (dane):
// Akcja bez payload
{ type: 'reset' }
// Akcja z payload
{ type: 'setUser', payload: { id: 1, name: 'Paweł' } }
// TypeScript union type dla wszystkich akcji
type Action =
| { type: 'reset' }
| { type: 'setUser'; payload: User }
| { type: 'increment'; payload: number };
TypeScript discriminated union zapewnia type safety!
Reducer to czysta funkcja: (state, action) => newState
Zasady reducera:
Czysta funkcja – ten sam input zawsze daje ten sam output
Nie mutuj state – zawsze zwracaj nowy obiekt
Brak side effects – nie rób fetch, nie zmieniaj DOM, nie wywołuj random()
Obsłuż wszystkie akcje – użyj default case
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'decrement':
return { ...state, count: state.count - 1 };
default:
return state; // WAŻNE: zawsze zwracaj state dla nieznanych akcji
}
}
dispatch to funkcja, którą wywołujesz z akcją:
dispatch({ type: 'increment' });
dispatch({ type: 'setUser', payload: user });
Możesz tworzyć action creators dla czytelności:
const increment = () => ({ type: 'increment' } as const);
const setUser = (user: User) => ({ type: 'setUser', payload: user } as const);
// Użycie
dispatch(increment());
dispatch(setUser(userData));
TypeScript w useReducer – best practices
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'incrementBy'; payload: number }
| { type: 'reset' };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
case 'incrementBy':
return state + action.payload; // TypeScript wie że payload istnieje!
case 'reset':
return 0;
default:
const _exhaustive: never = action; // Exhaustiveness checking
return state;
}
}
never type sprawdza czy obsłużyliśmy wszystkie przypadki!
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: 'all' | 'active' | 'completed' };
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload
};
default:
return state;
}
}
Praktyczny przykład: Formularz z walidacją
import { useReducer } from "react";
// State
interface FormState {
values: {
email: string;
password: string;
confirmPassword: string;
};
errors: {
email?: string;
password?: string;
confirmPassword?: string;
};
touched: {
email: boolean;
password: boolean;
confirmPassword: boolean;
};
isSubmitting: boolean;
submitError: string | null;
}
// Upewniamy się, że FormField odpowiada kluczom w errors
type FormField = keyof FormState["errors"];
// Action
type FormAction =
| { type: "SET_FIELD"; field: ValueField; value: string }
| { type: "SET_TOUCHED"; field: TouchedField }
| { type: "SET_ERROR"; field: ErrorField; error: string }
| { type: "CLEAR_ERROR"; field: ErrorField }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_SUCCESS" }
| { type: "SUBMIT_ERROR"; error: string }
| { type: "RESET" };
// initial State
const initialState: FormState = {
values: { email: "", password: "", confirmPassword: "" },
errors: {},
touched: { email: false, password: false, confirmPassword: false },
isSubmitting: false,
submitError: null,
};
// Reducer
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return {
...state,
values: { ...state.values, [action.field]: action.value },
};
case "SET_TOUCHED":
return {
...state,
touched: { ...state.touched, [action.field]: true },
};
case "SET_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case "CLEAR_ERROR": {
const { [action.field]: _, ...rest } = state.errors;
return { ...state, errors: rest };
}
case "SUBMIT_START":
return { ...state, isSubmitting: true, submitError: null };
case "SUBMIT_SUCCESS":
return { ...initialState };
case "SUBMIT_ERROR":
return { ...state, isSubmitting: false, submitError: action.error };
case "RESET":
return initialState;
default:
return state;
}
}
// Component
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
function handleChange(field: ValueField, value: string) {
dispatch({ type: "SET_FIELD", field, value });
if (field === "email" && !value.includes("@")) {
dispatch({
type: "SET_ERROR",
field: "email",
error: "Nieprawidłowy email",
});
} else {
dispatch({ type: "CLEAR_ERROR", field });
}
}
function handleBlur(field: TouchedField) {
dispatch({ type: "SET_TOUCHED", field });
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (state.values.password !== state.values.confirmPassword) {
dispatch({
type: "SET_ERROR",
field: "confirmPassword",
error: "Hasła nie pasują",
});
return;
}
dispatch({ type: "SUBMIT_START" });
try {
await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state.values),
});
dispatch({ type: "SUBMIT_SUCCESS" });
} catch (err) {
dispatch({
type: "SUBMIT_ERROR",
error: "Rejestracja nie powiodła się",
});
console.error(err);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="email"
value={state.values.email}
onChange={(e) => handleChange("email", e.target.value)}
onBlur={() => handleBlur("email")}
placeholder="Email"
/>
{state.touched.email && state.errors.email && (
<span className="error">{state.errors.email}</span>
)}
</div>
<div>
<input
type="password"
value={state.values.password}
onChange={(e) => handleChange("password", e.target.value)}
onBlur={() => handleBlur("password")}
placeholder="Hasło"
/>
{state.touched.password && state.errors.password && (
<span className="error">{state.errors.password}</span>
)}
</div>
<div>
<input
type="password"
value={state.values.confirmPassword}
onChange={(e) => handleChange("confirmPassword", e.target.value)}
onBlur={() => handleBlur("confirmPassword")}
placeholder="Potwierdź hasło"
/>
{state.touched.confirmPassword && state.errors.confirmPassword && (
<span className="error">{state.errors.confirmPassword}</span>
)}
</div>
{state.submitError && (
<div className="error">{state.submitError}</div>
)}
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? "Rejestracja..." : "Zarejestruj się"}
</button>
</form>
);
}
export default RegistrationForm;
Zalety tego podejścia:
Cała logika state w jednym miejscu (reducer)
Łatwe testowanie – reducer to czysta funkcja
Przewidywalne aktualizacje – zawsze przez akcje
TypeScript gwarantuje type safety
Łatwe debugowanie – widzisz wszystkie akcje
useReducer vs useState – kiedy co wybrać?
Stan jest prosty (number, string, boolean)
Niewiele powiązanych wartości
Prosta logika aktualizacji
Nie ma złożonych zależności między wartościami
// ✅ useState OK
const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
Stan jest złożony (nested objects, arrays)
Wiele powiązanych wartości
Złożona logika aktualizacji
Następny stan zależy od poprzedniego
Chcesz testować logikę state osobno
Masz wiele podobnych operacji na state
// ✅ useReducer lepszy
const [formState, dispatch] = useReducer(formReducer, initialFormState);
const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
Aspekt
useState
useReducer
Prostota
✅ Prostszy
❌ Bardziej verbose
Złożony state
❌ Trudny do zarządzania
✅ Świetny
Testowanie
❌ Trudne
✅ Łatwe (czysta funkcja)
Debugowanie
❌ Rozproszona logika
✅ Wszystko w reducerze
TypeScript
✅ Proste
✅ Świetne (discriminated unions)
Performance
✅ Lekki
✅ Podobny
useReducer + Context = Global State Management
Najlepszy pattern: połącz useReducer z Context dla globalnego state management!
// TodoContext.tsx
import { createContext, useReducer, useContext, ReactNode } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: 'all' | 'active' | 'completed' }
| { type: 'CLEAR_COMPLETED' };
const initialState: TodoState = {
todos: [],
filter: 'all'
};
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
// Context
interface TodoContextType {
state: TodoState;
dispatch: React.Dispatch<TodoAction>;
}
const TodoContext = createContext<TodoContextType | undefined>(undefined);
// Provider
export function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// Custom Hook
export function useTodos() {
const context = useContext(TodoContext);
if (!context) throw new Error('useTodos must be used within TodoProvider');
return context;
}
// Selectors (opcjonalnie)
export function useFilteredTodos() {
const { state } = useTodos();
switch (state.filter) {
case 'active':
return state.todos.filter(todo => !todo.completed);
case 'completed':
return state.todos.filter(todo => todo.completed);
default:
return state.todos;
}
}
Użycie w komponentach:
// TodoList.tsx
function TodoList() {
const { dispatch } = useTodos();
const todos = useFilteredTodos();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
Usuń
</button>
</li>
))}
</ul>
);
}
// AddTodo.tsx
function AddTodo() {
const [text, setText] = useState('');
const { dispatch } = useTodos();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD_TODO', payload: text });
setText('');
}
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} placeholder="Nowe zadanie" />
<button type="submit">Dodaj</button>
</form>
);
}
// FilterButtons.tsx
function FilterButtons() {
const { state, dispatch } = useTodos();
return (
<div>
<button
onClick={() => dispatch({ type: 'SET_FILTER', payload: 'all' })}
disabled={state.filter === 'all'}
>
Wszystkie
</button>
<button
onClick={() => dispatch({ type: 'SET_FILTER', payload: 'active' })}
disabled={state.filter === 'active'}
>
Aktywne
</button>
<button
onClick={() => dispatch({ type: 'SET_FILTER', payload: 'completed' })}
disabled={state.filter === 'completed'}
>
Ukończone
</button>
</div>
);
}
// App.tsx
function App() {
return (
<TodoProvider>
<div className="app">
<h1>Todo App</h1>
<AddTodo />
<FilterButtons />
<TodoList />
</div>
</TodoProvider>
);
}
To jest wzorzec używany w profesjonalnych aplikacjach! Reducer + Context = prostszy Redux.
Zaawansowane wzorce
Jeśli initial state wymaga kosztownych obliczeń:
function init(initialCount: number): State {
// Kosztowne obliczenia
return { count: initialCount * 2 };
}
const [state, dispatch] = useReducer(reducer, 10, init);
Dla czytelności i reużywalności:
// actions.ts
export const todoActions = {
addTodo: (text: string) => ({ type: 'ADD_TODO' as const, payload: text }),
toggleTodo: (id: number) => ({ type: 'TOGGLE_TODO' as const, payload: id }),
deleteTodo: (id: number) => ({ type: 'DELETE_TODO' as const, payload: id }),
};
// Użycie
dispatch(todoActions.addTodo('Nowe zadanie'));
Dla logowania, analytics, itp:
function useReducerWithMiddleware(
reducer: Reducer,
initialState: State
): [State, Dispatch<Action>] {
const [state, dispatch] = useReducer(reducer, initialState);
const dispatchWithMiddleware = (action: Action) => {
console.log('Action:', action);
console.log('Previous State:', state);
dispatch(action);
console.log('New State:', state);
};
return [state, dispatchWithMiddleware];
}
Testowanie reducerów
Reducery są czyste funkcje – łatwe do testowania!
// todoReducer.test.ts
import { todoReducer } from './todoReducer';
describe('todoReducer', () => {
test('ADD_TODO adds new todo', () => {
const initialState = { todos: [], filter: 'all' };
const action = { type: 'ADD_TODO' as const, payload: 'Test todo' };
const newState = todoReducer(initialState, action);
expect(newState.todos).toHaveLength(1);
expect(newState.todos[0].text).toBe('Test todo');
expect(newState.todos[0].completed).toBe(false);
});
test('TOGGLE_TODO toggles todo completion', () => {
const initialState = {
todos: [{ id: 1, text: 'Test', completed: false }],
filter: 'all'
};
const action = { type: 'TOGGLE_TODO' as const, payload: 1 };
const newState = todoReducer(initialState, action);
expect(newState.todos[0].completed).toBe(true);
});
});
Best Practices
// ✅ Dobrze
'ADD_TODO', 'TOGGLE_TODO', 'DELETE_TODO'
// ❌ Źle
'addTodo', 'toggleTodo', 'deleteTodo'
// User actions
'USER_LOGIN', 'USER_LOGOUT', 'USER_UPDATE'
// Cart actions
'CART_ADD_ITEM', 'CART_REMOVE_ITEM', 'CART_CLEAR'
// ❌ Mutacja - ŹLE
case 'ADD_TODO':
state.todos.push(newTodo);
return state;
// ✅ Immutability - DOBRZE
case 'ADD_TODO':
return { ...state, todos: [...state.todos, newTodo] };
function reducer(state: State, action: Action): State {
switch (action.type) {
// cases...
default:
return state; // ZAWSZE zwróć state!
}
}
// ❌ Side effects w reducerze - ŹLE
case 'ADD_TODO':
fetch('/api/todos', { method: 'POST' }); // NIE!
return { ...state, todos: [...state.todos, newTodo] };
// ✅ Side effects w komponencie - DOBRZE
function addTodo(text: string) {
dispatch({ type: 'ADD_TODO', payload: text });
fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) });
}
Podsumowanie
To był techniczny, ale bardzo ważny wpis! Nauczyliśmy się:
✅ Kiedy useState nie wystarcza – złożony state, powiązane wartości
✅ useReducer Hook – składnia, reducer, actions, dispatch
✅ TypeScript – discriminated unions, exhaustiveness checking
✅ Praktyczne przykłady – formularz z walidacją, todo app
✅ useReducer vs useState – kiedy co wybrać
✅ useReducer + Context – globalny state management
✅ Zaawansowane wzorce – lazy init, action creators, middleware
✅ Testowanie – jak testować reducery
✅ Best Practices – profesjonalne wzorce
useReducer to potężne narzędzie dla złożonych stanów. Łącząc go z Context otrzymujesz prosty, ale skuteczny state management bez zewnętrznych bibliotek!
Przepisz swoją Todo App używając useReducer
Dodaj obsługę edycji todo (akcja EDIT_TODO )
Dodaj localStorage persistence w middleware
(Bonus) Napisz testy dla reducera