Komplexe State-Logik strukturiert verwalten
Sobald mehrere useState-Aufrufe zusammengehören und sich gegenseitig beeinflussen, wird der Code schnell unübersichtlich. useReducer bündelt zusammenhängende State-Übergänge in einer zentralen, testbaren Funktion.
Inhaltsverzeichnis
- 1. Das Problem mit vielen zusammenhängenden useState-Aufrufen
- 2. Beispiel: verstreute Update-Logik mit useState
- 3. Grundlagen: useReducer als zentrale State-Übergangsfunktion
- 4. Testbarkeit: Reducer als reine Funktion isoliert prüfen
- 5. Vergleich zu Redux im Kleinen
- 6. Action-Typen strukturiert benennen
- 7. useReducer mit Context kombinieren
- 8. Wann sich der Umstieg von useState auf useReducer lohnt
- 9. Praktische Einordnung für die eigene Codebasis
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit vielen zusammenhängenden useState-Aufrufen
Ein mehrstufiges Formular oder ein komplexer Dialog beginnt häufig harmlos mit zwei oder drei useState-Aufrufen, wächst aber mit jeder neuen Anforderung weiter. Irgendwann verwaltet eine Komponente sieben oder acht einzelne State-Variablen, von denen viele voneinander abhängen: Ändert sich das ausgewählte Land, muss die Region zurückgesetzt werden, ändert sich der Zahlungsart-Typ, müssen bestimmte Validierungsfehler gelöscht werden.
Das Problem ist nicht die Anzahl der useState-Aufrufe an sich, sondern dass die Logik, die diese Zustände miteinander koordiniert, über mehrere Event-Handler verstreut wird. Jeder Handler, der mehrere setState-Aufrufe kombiniert, muss dieselben Abhängigkeitsregeln erneut korrekt umsetzen, was Inkonsistenzen und vergessene Randfälle begünstigt, besonders wenn mehrere Entwicklerinnen und Entwickler an derselben Komponente arbeiten.
2. Beispiel: verstreute Update-Logik mit useState
Im folgenden Ausschnitt eines Checkout-Formulars zeigt sich das Muster deutlich: Der Handler für die Länderauswahl muss selbst daran denken, die Region zurückzusetzen, und der Handler für die Zahlungsart muss selbst daran denken, veraltete Fehler zu löschen. Vergisst man diese Kopplung an einer Stelle, etwa bei einem neuen Handler, der später hinzugefügt wird, entsteht ein Bug, der sich nur schwer über Code-Review auffinden lässt.
Je mehr solcher Abhängigkeiten existieren, desto mehr Handler müssen koordiniert bleiben, und die Wahrscheinlichkeit steigt, dass eine Regel bei einer Änderung übersehen wird. Der State selbst bleibt dabei technisch korrekt typisiert, aber die Konsistenzregeln zwischen den Feldern leben implizit verteilt im Code statt an einer zentralen Stelle.
function CheckoutForm() {
const [country, setCountry] = useState('DE');
const [region, setRegion] = useState('');
const [paymentType, setPaymentType] = useState('card');
const [errors, setErrors] = useState({});
function handleCountryChange(newCountry) {
setCountry(newCountry);
setRegion(''); // muss man selbst nicht vergessen
}
function handlePaymentTypeChange(newType) {
setPaymentType(newType);
setErrors((prev) => {
const { paymentType, ...rest } = prev;
return rest; // muss man selbst nicht vergessen
});
}
// ... weitere verstreute Handler
}
3. Grundlagen: useReducer als zentrale State-Übergangsfunktion
useReducer verlagert die Update-Logik in eine einzelne, reine Funktion, den Reducer, der aus aktuellem State und einer Action den neuen State berechnet. Statt mehrerer setState-Aufrufe in verschiedenen Handlern ruft man an jeder Stelle nur dispatch(action) auf, und der Reducer entscheidet zentral, wie sich der State als Ganzes verändert, inklusive aller Abhängigkeiten zwischen den Feldern.
Der entscheidende strukturelle Vorteil ist, dass die Koordinationsregel 'Land ändert sich, also Region zurücksetzen' nur noch an einer einzigen Stelle im Code steht, im case 'country/changed' des Reducers, statt potenziell in jedem Handler, der das Land ändern könnte, erneut implementiert zu werden.
const initialState = { country: 'DE', region: '', paymentType: 'card', errors: {} };
function checkoutReducer(state, action) {
switch (action.type) {
case 'country/changed':
return { ...state, country: action.country, region: '' };
case 'payment/changed': {
const { paymentType: _removed, ...restErrors } = state.errors;
return { ...state, paymentType: action.paymentType, errors: restErrors };
}
default:
return state;
}
}
function CheckoutForm() {
const [state, dispatch] = useReducer(checkoutReducer, initialState);
return (
<CountrySelect
value={state.country}
onChange={(c) => dispatch({ type: 'country/changed', country: c })}
/>
);
}
4. Testbarkeit: Reducer als reine Funktion isoliert prüfen
Ein Reducer ist eine pure Funktion: gleicher State und gleiche Action erzeugen immer denselben neuen State, ohne Seiteneffekte und ohne Abhängigkeit vom React-Rendering-Zyklus. Das macht ihn direkt und isoliert testbar, ganz ohne render oder renderHook, indem man den Reducer einfach mit Test-States und Test-Actions aufruft und das Ergebnis mit dem erwarteten neuen State vergleicht.
Diese Eigenschaft ist ein handfester praktischer Vorteil gegenüber über Handler verstreuter useState-Logik, die sich nur über das Rendern der Komponente und das Simulieren von Nutzerinteraktionen testen lässt. Bei komplexen State-Übergängen mit vielen Randfällen, etwa einem Warenkorb mit Mengenrabatten, lässt sich so jede einzelne Regel gezielt mit einem eigenen Testfall abdecken.
// checkoutReducer.test.js
test('setzt Region zurueck wenn sich das Land aendert', () => {
const state = { country: 'DE', region: 'Bayern', paymentType: 'card', errors: {} };
const next = checkoutReducer(state, { type: 'country/changed', country: 'AT' });
expect(next.country).toBe('AT');
expect(next.region).toBe('');
});
5. Vergleich zu Redux im Kleinen
useReducer folgt konzeptionell exakt demselben Muster wie Redux: Ein zentraler Reducer verarbeitet Actions mit einem type-Feld und berechnet daraus deterministisch den neuen State. Wer bereits mit Redux gearbeitet hat, erkennt useReducer häufig als 'Redux im Kleinen' für eine einzelne Komponente, ohne globalen Store, ohne Middleware und ohne zusätzliche Abhängigkeit.
Der zentrale Unterschied ist der Geltungsbereich: useReducer-State lebt lokal in einer Komponente oder wird über Context an einen Unterbaum weitergegeben, während Redux einen einzigen globalen Store für die gesamte Anwendung verwaltet, inklusive Middleware für asynchrone Aktionen, Time-Travel-Debugging und DevTools-Integration. Für lokale, komponentenbezogene Komplexität ist useReducer meist völlig ausreichend, ohne den zusätzlichen Overhead einer externen Bibliothek.
6. Action-Typen strukturiert benennen
Eine bewährte Konvention ist, Action-Typen als Ereignis zu benennen, das in der Benutzeroberfläche passiert ist, etwa 'country/changed' statt 'SET_COUNTRY'. Diese Konvention, oft aus dem Redux-Umfeld übernommen, betont, dass ein Reducer nicht direkt Setter-Aufrufe abbildet, sondern auf Ereignisse reagiert und selbst entscheidet, wie der State darauf reagieren soll.
Diese Denkweise verhindert eine typische Falle: Reducer, die de facto nur als Umweg für setState dienen, mit Action-Typen wie 'SET_X' für jedes einzelne Feld. Solche Reducer bringen keinen strukturellen Vorteil gegenüber useState, weil die Koordinationslogik zwischen den Feldern gar nicht erst im Reducer landet, sondern weiterhin im aufrufenden Code verstreut bleibt.
7. useReducer mit Context kombinieren
Für State, der von mehreren tief verschachtelten Komponenten gemeinsam gelesen und verändert werden muss, lässt sich useReducer mit der Context API kombinieren: Der state und die dispatch-Funktion werden über einen Context bereitgestellt, sodass jede Komponente im Unterbaum Actions auslösen kann, ohne dass Props über mehrere Ebenen durchgereicht werden müssen.
Dieses Muster wird oft als 'leichtgewichtiges Redux' bezeichnet und eignet sich gut für mittelgroße Anwendungsbereiche, etwa einen komplexen Formular-Assistenten mit mehreren Schritten in unterschiedlichen Komponenten. Bei sehr großer, App-weiter Komplexität mit vielen unabhängigen Datenbereichen stößt dieser Ansatz an dieselben Grenzen wie ein einzelner großer Context, weil auch hier alle Consumer bei jeder Dispatch-Aktion potenziell neu rendern.
const CheckoutContext = createContext(null);
function CheckoutProvider({ children }) {
const [state, dispatch] = useReducer(checkoutReducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state]);
return (
<CheckoutContext.Provider value={value}>
{children}
</CheckoutContext.Provider>
);
}
function PaymentStep() {
const { state, dispatch } = useContext(CheckoutContext);
return (
<button onClick={() => dispatch({ type: 'payment/changed', paymentType: 'paypal' })}>
PayPal waehlen
</button>
);
}
8. Wann sich der Umstieg von useState auf useReducer lohnt
Ein klares Signal für den Umstieg ist, wenn mehrere State-Variablen bei denselben Ereignissen gemeinsam aktualisiert werden müssen, wenn Update-Logik sich zwischen mehreren Handlern wiederholt, oder wenn der nächste State vom vorherigen State auf komplexe Weise abhängt, etwa bei einem Formular-Wizard mit bedingten Schritten. Auch wenn eine Komponente unabhängig von Rendering isoliert testbare Geschäftslogik enthält, spricht das für einen Reducer.
Umgekehrt lohnt sich der Umstieg nicht, wenn State-Variablen wirklich unabhängig voneinander sind und keine gemeinsame Koordinationslogik existiert. Ein einfaches Toggle für ein Dropdown oder ein einzelnes Eingabefeld profitiert nicht von einem Reducer, der hier nur unnötige Indirektion hinzufügen würde. Die Entscheidung sollte an der tatsächlichen Kopplung zwischen den State-Werten hängen, nicht an der reinen Anzahl der useState-Aufrufe.
9. Praktische Einordnung für die eigene Codebasis
In der Praxis hat sich bewährt, mit useState zu beginnen und erst dann zu useReducer zu wechseln, wenn die Koordinationsprobleme tatsächlich sichtbar werden, statt vorab zu antizipieren. Ein verfrühter Umstieg auf useReducer für simplen, unabhängigen State erzeugt unnötige Indirektion und erschwert es neuen Teammitgliedern, den Datenfluss nachzuvollziehen.
Die folgende Tabelle fasst die wichtigsten Entscheidungskriterien zusammen und ordnet ein, wann useState, useReducer oder eine externe Library die passendere Wahl ist.
| Situation | useState | useReducer | Redux/externe Library |
|---|---|---|---|
| Wenige, unabhängige Felder | gut geeignet | unnötige Indirektion | übertrieben |
| Mehrere abhängige Felder mit gemeinsamen Regeln | verstreute Logik | gut geeignet | möglich, aber Overhead |
| Isoliert testbare Geschäftslogik nötig | schwer testbar | gut geeignet | gut geeignet |
| State über viele Komponenten der ganzen App verteilt | unpraktikabel | mit Context begrenzt geeignet | gut geeignet |
Mironsoft
React-Architektur, Performance und Magento-Frontend-Integration
React-Frontends, die schnell bleiben statt mit jedem Feature langsamer zu werden?
Wir prüfen bestehende React-Anwendungen auf unnötige Re-Renders, aufgeblähte Bundles und fragile State-Verwaltung und bauen daraus ein Frontend, das performant bleibt und sich sauber an Magento oder andere Backends anbindet.
Performance-Audit
Re-Renders, Bundle-Größe und Ladezeiten systematisch messen und beheben.
State-Architektur
Context, Zustand und Server State sauber trennen statt alles in einen Topf zu werfen.
Magento-Integration
GraphQL- oder REST-Anbindung an Magento robust und typsicher aufbauen.
10. Zusammenfassung
useReducer: Das Wichtigste auf einen Blick
Kernidee
Ein zentraler, reiner Reducer statt Update-Logik verstreut über mehrere Handler.
Größter Vorteil
Isolierte Testbarkeit als pure Funktion ohne Rendering-Kontext.
Verhältnis zu Redux
Gleiches Konzept im Kleinen, lokal statt global, ohne Middleware.
Umstiegssignal
Mehrere State-Variablen müssen bei denselben Ereignissen koordiniert aktualisiert werden.