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>
);
}
reducer(state, action)
: Czysta funkcja, która przyjmuje aktualny stan i obiekt akcji, a następnie zwraca nowy stan. Nie powinna modyfikować istniejącego stanu ani wykonywać efektów ubocznych.initialState
: Początkowa wartość stanu.useReducer(reducer, initialState)
: Wywołanie Hooka zwraca tablicę z dwoma elementami:state
: Aktualna wartość stanu zarządzanego przez reduktor.dispatch
: Funkcja, którą wywołujemy, aby wysłać (dispatch) akcję do reduktora. Akcja to zazwyczaj obiekt z właściwościątype
(opisującą rodzaj zmiany) i opcjonalniepayload
(zawierającą dodatkowe dane potrzebne do aktualizacji).
Kiedy używać `useReducer` zamiast `useState`?
- Złożona logika stanu: Gdy następny stan zależy od poprzedniego w skomplikowany sposób, lub gdy logika aktualizacji jest rozbudowana. Reduktor centralizuje tę logikę.
- Stan z wieloma pod-wartościami: Gdy stan jest obiektem z wieloma polami, które często aktualizują się razem.
useReducer
ułatwia zarządzanie takimi obiektami w porównaniu do wielokrotnego użyciauseState
i ręcznego łączenia obiektów. - Optymalizacja wydajności: W niektórych przypadkach, gdy przekazujemy funkcję
dispatch
w dół drzewa komponentów, może to być bardziej wydajne niż przekazywanie wielu funkcji zwrotnych zuseState
, ponieważ sama funkcjadispatch
jest stabilna (nie zmienia się między renderowaniami). - Łatwiejsze testowanie: Logikę aktualizacji stanu (reduktor) można testować niezależnie od komponentu UI, ponieważ jest to czysta funkcja.
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:
- Zdefiniuj stan początkowy dla reduktora, zawierający
count
istep
. - Napisz funkcję
reducer
, która obsługuje akcje:\"increment\"
,\"decrement\"
,\"setStep\"
,\"reset\"
. Akcja\"setStep\"
powinna przyjmować nową wartość kroku wpayload
. - W komponencie
CounterWithReducer
(nowa nazwa lub refaktoryzacja istniejącego) użyjuseReducer
z utworzonym stanem początkowym i reduktorem. - Zaktualizuj handlery zdarzeń (
handleIncrement
,handleDecrement
,handleStepChange
,handleReset
), aby wywoływałydispatch
z odpowiednimi obiektami akcji. - 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.
- Zdefiniuj stan początkowy jako obiekt z tablicą zadań, np.
{ todos: [] }
. - Napisz reduktor obsługujący akcje
\"addTodo\"
(z tekstem zadania wpayload
) i\"removeTodo\"
(z ID zadania do usunięcia wpayload
). Pamiętaj o generowaniu unikalnych ID dla nowych zadań. - W komponencie
TodoList
użyjuseReducer
. - Stwórz prosty formularz (input + przycisk) do dodawania zadań, który dispatchuje akcję
\"addTodo\"
. - 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.