Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Udostępnij Udostępnij Kontakt
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.

Problem 1: Wiele powiązanych wartości state

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
}

Problem 2: Złożona logika aktualizacji

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

Problem 3: Stan zależy od poprzedniego stanu w wielu miejscach

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.

Podstawowa składnia

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

Prosty przykład: Licznik

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?

  1. dispatch({ type: 'increment' }) – wysyłamy akcję
  2. React wywołuje counterReducer(currentState, action)
  3. Reducer zwraca nowy stan
  4. React re-renderuje komponent z nowym stanem
Anatomia useReducer

State

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

Actions

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

Reducer to czysta funkcja: (state, action) => newState

Zasady reducera:

  1. Czysta funkcja – ten sam input zawsze daje ten sam output
  2. Nie mutuj state – zawsze zwracaj nowy obiekt
  3. Brak side effects – nie rób fetch, nie zmieniaj DOM, nie wywołuj random()
  4. 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

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

Pattern 1: Discriminated Union dla Actions

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!

Pattern 2: Typy State i Actions osobno

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ć?

Używaj useState gdy:

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

Używaj useReducer gdy:

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

Porównanie

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

Lazy initialization

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

Action Creators

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

Middleware pattern

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

1. Nazywaj akcje UPPERCASE_WITH_UNDERSCORES

// ✅ Dobrze
'ADD_TODO', 'TOGGLE_TODO', 'DELETE_TODO'

// ❌ Źle
'addTodo', 'toggleTodo', 'deleteTodo'

2. Grupuj powiązane akcje prefixem

// User actions
'USER_LOGIN', 'USER_LOGOUT', 'USER_UPDATE'

// Cart actions
'CART_ADD_ITEM', 'CART_REMOVE_ITEM', 'CART_CLEAR'

3. Zawsze zwracaj nowy obiekt/array

// ❌ Mutacja - ŹLE
case 'ADD_TODO':
  state.todos.push(newTodo);
  return state;

// ✅ Immutability - DOBRZE
case 'ADD_TODO':
  return { ...state, todos: [...state.todos, newTodo] };

4. Użyj default case

function reducer(state: State, action: Action): State {
  switch (action.type) {
    // cases...
    default:
      return state; // ZAWSZE zwróć state!
  }
}

5. Trzymaj reducer czysty

// ❌ 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!

Zadanie dla Ciebie

  1. Przepisz swoją Todo App używając useReducer
  2. Dodaj obsługę edycji todo (akcja EDIT_TODO)
  3. Dodaj localStorage persistence w middleware
  4. (Bonus) Napisz testy dla reducera