Der useReducer-Hook in React: Komplexen State strukturiert verwalten
Wenn useState an seine Grenzen stößt, kommt useReducer ins Spiel. Du lernst, wie du zusammenhängende State-Logik an einer zentralen Stelle bündelst – vorhersehbar und leicht testbar.
Du kennst useState und nutzt ihn ständig. Doch irgendwann wächst der State einer Komponente: mehrere Werte hängen voneinander ab, Updates verteilen sich über viele Event-Handler, und du verlierst langsam den Überblick. Genau hier setzt useReducer an. Der Hook bündelt die gesamte State-Logik an einer einzigen, gut testbaren Stelle und macht Zustandsänderungen vorhersehbar. In diesem Beitrag schauen wir uns an, wann sich der Umstieg lohnt und wie du useReducer sauber einsetzt.
Was ist ein Reducer überhaupt?
Ein Reducer ist eine reine Funktion, die zwei Argumente bekommt: den aktuellen State und eine Action, die beschreibt, was passieren soll. Zurück gibt sie den neuen State. Das Muster kennst du vielleicht schon von Array.prototype.reduce – dort wird aus vielen Werten Schritt für Schritt ein Ergebnis aufgebaut. Genauso baut ein Reducer aus altem State plus Action den neuen State.
Wichtig: Ein Reducer muss rein sein. Er darf den bestehenden State nicht direkt verändern und keine Seiteneffekte auslösen, sondern immer ein neues Objekt zurückgeben.
function counterReducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: 0 };
default:
throw new Error("Unbekannte Action: " + action.type);
}
}useReducer in der Komponente nutzen
Der Hook gibt dir – ähnlich wie useState – ein Array mit zwei Elementen zurück: den aktuellen State und eine dispatch-Funktion. Über dispatch schickst du Actions an den Reducer. Der zweite Parameter von useReducer ist der Startwert des States.
import { useReducer } from "react";
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Zähler: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}Beachte, wie schlank die Komponente bleibt: Sie beschreibt nur noch, welche Action bei welchem Klick ausgelöst wird. Das Wie der Zustandsänderung steckt komplett im Reducer.
Wann useReducer statt useState?
Nicht jeder State braucht einen Reducer. Für einen einzelnen Boolean oder ein Textfeld ist useState völlig ausreichend. Der Umstieg lohnt sich, wenn eine oder mehrere der folgenden Bedingungen zutreffen:
- Der State besteht aus mehreren zusammenhängenden Werten, die gemeinsam aktualisiert werden.
- Der nächste State hängt stark vom vorherigen State ab.
- Die Update-Logik ist so komplex, dass sie sich über viele Handler verteilt und schwer nachvollziehbar wird.
- Du möchtest die Logik isoliert testen – ein Reducer ist eine simple Funktion und ohne React testbar.
Ein typisches Beispiel ist ein Formular mit Validierung und Ladezustand. Statt drei bis vier useState-Aufrufe zu jonglieren, bündelst du alles in einem Reducer.
Ein realistisches Beispiel: Formular-State
Schauen wir uns an, wie ein Login-Formular mit Feldwerten, Ladezustand und Fehlermeldung aussehen kann. Die Actions tragen hier zusätzlich Daten im payload-Feld.
const initialState = {
email: "",
password: "",
loading: false,
error: null,
};
function formReducer(state, action) {
switch (action.type) {
case "field":
return { ...state, [action.field]: action.value };
case "submit":
return { ...state, loading: true, error: null };
case "success":
return { ...state, loading: false };
case "error":
return { ...state, loading: false, error: action.message };
default:
return state;
}
}
function LoginForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
function handleChange(e) {
dispatch({ type: "field", field: e.target.name, value: e.target.value });
}
async function handleSubmit(e) {
e.preventDefault();
dispatch({ type: "submit" });
try {
await login(state.email, state.password);
dispatch({ type: "success" });
} catch (err) {
dispatch({ type: "error", message: "Login fehlgeschlagen" });
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={state.email} onChange={handleChange} />
<input name="password" type="password" value={state.password} onChange={handleChange} />
{state.error && <p>{state.error}</p>}
<button disabled={state.loading}>Anmelden</button>
</form>
);
}Der "field"-Fall nutzt einen berechneten Property-Namen ([action.field]), um mit einer einzigen Action jedes beliebige Feld zu aktualisieren. So bleibt der Reducer kompakt, egal wie viele Eingabefelder dazukommen.
Lazy Initialization für teure Startwerte
useReducer akzeptiert ein drittes Argument: eine Init-Funktion. Sie wird nur einmal beim ersten Rendern aufgerufen und ist ideal, wenn der Startwert aufwendig berechnet werden muss. Das zweite Argument dient dann als Eingabe für diese Funktion.
function init(startCount) {
return { count: startCount };
}
function Counter({ startCount }) {
const [state, dispatch] = useReducer(counterReducer, startCount, init);
// init(startCount) läuft nur beim ersten Render
return <p>{state.count}</p>;
}Dieser Ansatz ist auch praktisch, um eine reset-Action sauber umzusetzen: Du kannst dieselbe init-Funktion im Reducer erneut aufrufen, um den Ausgangszustand wiederherzustellen.
Fazit
useReducer ist kein Ersatz für useState, sondern das passende Werkzeug für komplexeren, zusammenhängenden State. Indem du die Logik in einen reinen Reducer auslagerst, werden deine Komponenten schlanker, deine Zustandsänderungen vorhersehbar und deine Logik ohne React testbar. Als Faustregel gilt: Sobald du mehrere useState-Aufrufe hast, die zusammen einen Zustand bilden, oder deine Update-Logik verzweigt wird, ist es Zeit für einen Reducer. In Kombination mit der Context API lässt sich useReducer übrigens zu einer leichtgewichtigen State-Management-Lösung ausbauen – aber das ist ein Thema für einen eigenen Beitrag.