Lekcja 17: Hook useReducer

Hook useState jest doskonały do zarządzania prostym stanem w komponentach funkcyjnych. Jednak gdy logika aktualizacji stanu staje się bardziej złożona lub gdy stan składa się z wielu powiązanych ze sobą wartości, zarządzanie nim za pomocą wielu wywołań useState może stać się uciążliwe. W takich sytuacjach React oferuje alternatywę w postaci Hooka useReducer.

Czym jest `useReducer`?

useReducer to Hook inspirowany wzorcem reduktora (reducer) znanym z bibliotek takich jak Redux. Pozwala na zarządzanie stanem komponentu poprzez centralizację logiki aktualizacji w jednej funkcji zwanej reduktorem.

Składnia:**

import React, { useReducer } from \"react\";

// 1. Funkcja reduktora
function reducer(state, action) {
  // Na podstawie typu akcji (action.type) zwraca nowy stan
  switch (action.type) {
    case \"increment\":
      return { count: state.count + 1 };
    case \"decrement\":
      return { count: state.count - 1 };
    case \"reset\":
      return { count: action.payload }; // payload to opcjonalne dane w akcji
    default:
      throw new Error(\"Nieznany typ akcji\");
      // lub return state; jeśli ignorujemy nieznane akcje
  }
}

function MyComponent() {
  // 2. Wywołanie useReducer
  const initialState = { count: 0 };
  const [state, dispatch] = useReducer(reducer, initialState);

  // state: aktualny stan (np. { count: 0 })
  // dispatch: funkcja do wysyłania akcji do reduktora

  return (
    <div>
      Licznik: {state.count}
      {/* 3. Wysyłanie akcji za pomocą dispatch */}
      <button onClick={() => dispatch({ type: \"increment\" })}>+</button>
      <button onClick={() => dispatch({ type: \"decrement\" })}>-</button>
      <button onClick={() => dispatch({ type: \"reset\", payload: 0 })}>Resetuj</button>
    </div>
  );
}

Kiedy używać `useReducer` zamiast `useState`?

Przykład: Zarządzanie Stanem Formularza

useReducer może uprościć zarządzanie stanem formularza z wieloma polami i potencjalnie złożoną walidacją.

import React, { useReducer } from \"react\";

const initialState = {
  name: \"\",
  email: \"\",
  message: \"\",
  status: \"idle\", // \"idle\", \"submitting\", \"success\", \"error\"
  error: null
};

function formReducer(state, action) {
  switch (action.type) {
    case \"updateField\":
      return { 
          ...state, 
          [action.field]: action.value // Aktualizuj konkretne pole
      };
    case \"submit\":
      return { ...state, status: \"submitting\", error: null };
    case \"submitSuccess\":
      return { ...initialState, status: \"success\" }; // Resetuj po sukcesie
    case \"submitError\":
      return { ...state, status: \"error\", error: action.error };
    case \"reset\":
        return initialState;
    default:
      return state;
  }
}

function ComplexForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (e) => {
    dispatch({
      type: \"updateField\",
      field: e.target.name,
      value: e.target.value
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    dispatch({ type: \"submit\" });

    // Symulacja wysłania danych
    setTimeout(() => {
      if (state.name && state.email && state.message) {
        console.log(\"Wysyłanie:\", state);
        dispatch({ type: \"submitSuccess\" });
      } else {
        dispatch({ type: \"submitError\", error: \"Wszystkie pola są wymagane!\" });
      }
    }, 1500);
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: \"flex\", flexDirection: \"column\", gap: \"10px\", maxWidth: \"400px\" }}>
      <h2>Złożony Formularz z useReducer</h2>
      <input name=\"name\" value={state.name} onChange={handleChange} placeholder=\"Imię\" disabled={state.status === \"submitting\"} />
      <input name=\"email\" type=\"email\" value={state.email} onChange={handleChange} placeholder=\"Email\" disabled={state.status === \"submitting\"} />
      <textarea name=\"message\" value={state.message} onChange={handleChange} placeholder=\"Wiadomość\" disabled={state.status === \"submitting\"} />
      
      <button type=\"submit\" disabled={state.status === \"submitting\"}>
        {state.status === \"submitting\" ? \"Wysyłanie...\" : \"Wyślij\"}
      </button>

      {state.status === \"success\" && <p style={{ color: \"green\" }}>Wiadomość wysłana pomyślnie!</p>}
      {state.status === \"error\" && <p style={{ color: \"red\" }}>Błąd: {state.error}</p>}
    </form>
  );
}

export default ComplexForm;

Leniwa Inicjalizacja (Lazy Initialization)

Jeśli tworzenie stanu początkowego jest kosztowne, można przekazać funkcję inicjalizującą jako trzeci argument do useReducer. Stan początkowy zostanie wtedy ustawiony na wynik wywołania init(initialArg).

function createInitialState(user) {
  // Potencjalnie kosztowne obliczenia...
  return { count: user.initialCount || 0 };
}

function CounterWithLazyInit({ user }) {
  // Funkcja createInitialState zostanie wywołana tylko raz
  const [state, dispatch] = useReducer(reducer, user, createInitialState);
  // ... reszta komponentu
}

Ćwiczenie praktyczne

Pokaż rozwiązanie
import React, { useReducer } from \"react\";

const initialState = { count: 0, step: 1 };

function reducer(state, action) {
  console.log(\"Reducer:\", state, action); // Pomocne przy debugowaniu
  switch (action.type) {
    case \"increment\":
      return { ...state, count: state.count + state.step };
    case \"decrement\":
      return { ...state, count: state.count - state.step };
    case \"setStep\":
      return { ...state, step: action.payload };
    case \"reset\":
      return initialState;
    default:
      throw new Error(\"Nieznany typ akcji\");
  }
}

function CounterWithReducer() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state; // Destrukturyzacja stanu dla łatwiejszego dostępu

  const handleIncrement = () => {
    dispatch({ type: \"increment\" });
  };

  const handleDecrement = () => {
    dispatch({ type: \"decrement\" });
  };

  const handleReset = () => {
    dispatch({ type: \"reset\" });
  };

  const handleStepChange = (e) => {
    dispatch({ type: \"setStep\", payload: Number(e.target.value) || 1 });
  };

  return (
    <div>
      <h2>Licznik (useReducer)</h2>
      <p>Wartość: {count}</p>
      <div>
        <button onClick={handleDecrement}>-</button>
        <button onClick={handleIncrement}>+</button>
      </div>
      <div>
        <label>
          Krok: 
          <input type=\"number\" value={step} onChange={handleStepChange} />
        </label>
      </div>
      <button onClick={handleReset}>Resetuj</button>
    </div>
  );
}

export default CounterWithReducer;

Cel: Zrefaktoryzować prosty komponent licznika, który używa wielu useState, aby używał useReducer.

Kod początkowy (z `useState`):**

import React, { useState } from \"react\";

function CounterWithState() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  const handleIncrement = () => {
    setCount(c => c + step);
  };

  const handleDecrement = () => {
    setCount(c => c - step);
  };

  const handleReset = () => {
    setCount(0);
    setStep(1);
  };

  const handleStepChange = (e) => {
    setStep(Number(e.target.value) || 1);
  };

  return (
    <div>
      <h2>Licznik (useState)</h2>
      <p>Wartość: {count}</p>
      <div>
        <button onClick={handleDecrement}>-</button>
        <button onClick={handleIncrement}>+</button>
      </div>
      <div>
        <label>
          Krok: 
          <input type=\"number\" value={step} onChange={handleStepChange} />
        </label>
      </div>
      <button onClick={handleReset}>Resetuj</button>
    </div>
  );
}

export default CounterWithState;

Kroki:

  1. Zdefiniuj stan początkowy dla reduktora, zawierający count i step.
  2. Napisz funkcję reducer, która obsługuje akcje: \"increment\", \"decrement\", \"setStep\", \"reset\". Akcja \"setStep\" powinna przyjmować nową wartość kroku w payload.
  3. W komponencie CounterWithReducer (nowa nazwa lub refaktoryzacja istniejącego) użyj useReducer z utworzonym stanem początkowym i reduktorem.
  4. Zaktualizuj handlery zdarzeń (handleIncrement, handleDecrement, handleStepChange, handleReset), aby wywoływały dispatch z odpowiednimi obiektami akcji.
  5. Upewnij się, że komponent nadal działa tak samo jak wersja z useState.

Zadanie do samodzielnego wykonania

Stwórz prostą listę zadań (Todo List) z możliwością dodawania i usuwania zadań, używając useReducer do zarządzania stanem listy.

  1. Zdefiniuj stan początkowy jako obiekt z tablicą zadań, np. { todos: [] }.
  2. Napisz reduktor obsługujący akcje \"addTodo\" (z tekstem zadania w payload) i \"removeTodo\" (z ID zadania do usunięcia w payload). Pamiętaj o generowaniu unikalnych ID dla nowych zadań.
  3. W komponencie TodoList użyj useReducer.
  4. Stwórz prosty formularz (input + przycisk) do dodawania zadań, który dispatchuje akcję \"addTodo\".
  5. Wyrenderuj listę zadań, a przy każdym zadaniu dodaj przycisk "Usuń", który dispatchuje akcję \"removeTodo\" z odpowiednim ID.

FAQ - Hook useReducer

Czy `useReducer` jest lepszy niż `useState`?

Niekoniecznie. Oba Hooki służą do zarządzania stanem, ale mają różne zastosowania. `useState` jest prostszy i idealny dla niezależnych, prostych wartości stanu. `useReducer` lepiej sprawdza się przy złożonej logice aktualizacji stanu, stanie z wieloma powiązanymi polami lub gdy chcemy zoptymalizować przekazywanie funkcji aktualizujących w dół drzewa.

Czy reduktor musi być czystą funkcją?

Tak, reduktor musi być czystą funkcją. Oznacza to, że dla tych samych argumentów (stan, akcja) zawsze musi zwracać ten sam wynik (nowy stan) i nie może powodować efektów ubocznych (np. modyfikować oryginalnego stanu, wykonywać zapytań API, ustawiać timerów).

Jak obsługiwać asynchroniczne operacje (np. fetch) z `useReducer`?

Reduktor sam w sobie jest synchroniczny i nie powinien wykonywać operacji asynchronicznych. Operacje asynchroniczne (np. zapytania API) należy wykonywać w komponencie (np. w handlerze zdarzenia lub w `useEffect`), a następnie dispatchować akcje do reduktora, aby zaktualizować stan na podstawie wyniku operacji (np. akcja `fetchStart`, `fetchSuccess` z danymi, `fetchError` z błędem).

Czy funkcja `dispatch` zmienia się między renderowaniami?

Nie, React gwarantuje, że funkcja `dispatch` zwrócona przez `useReducer` jest stabilna i nie zmienia się między kolejnymi renderowaniami komponentu. Oznacza to, że można ją bezpiecznie pomijać w tablicach zależności `useEffect` lub `useCallback`.

Czy mogę używać wielu `useReducer` w jednym komponencie?

Tak, podobnie jak `useState`, można używać `useReducer` wielokrotnie w jednym komponencie do zarządzania różnymi, niezależnymi fragmentami stanu za pomocą różnych reduktorów.

Jak przekazać `dispatch` do komponentów potomnych?

Funkcję `dispatch` można przekazywać w dół drzewa komponentów jako props. Ponieważ jest ona stabilna, nie powoduje to problemów z wydajnością związanych z tworzeniem nowych funkcji przy każdym renderowaniu. Można ją również umieścić w wartości kontekstu (Context API), aby uniknąć "prop drilling".

Czy `useReducer` jest podobny do Reduxa?

Tak, `useReducer` jest inspirowany Reduxem i używa podobnego wzorca reduktora i akcji. Jednak `useReducer` jest wbudowanym Hookiem Reacta do zarządzania stanem *lokalnym* komponentu (lub poddrzewa, jeśli `dispatch` jest przekazywany w dół). Redux jest zewnętrzną biblioteką do zarządzania stanem *globalnym* całej aplikacji, oferując dodatkowe funkcje jak middleware, narzędzia deweloperskie itp.