useReducer: Komplexe State-Logik strukturiert verwalten
AI generated
{ }
React 19 · State Management · Hooks
useReducer
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.

14 Min. Lesezeit State Management Reducer Pattern Formulare

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.

11. FAQ: useReducer: Das Wichtigste auf einen Blick

1Was ist der grundlegende Unterschied zwischen useState und useReducer?
useState verwaltet einen einzelnen Zustandswert direkt über eine Setter-Funktion. useReducer bündelt State-Übergänge in einer zentralen Funktion, dem Reducer, die aus aktuellem State und einer Action den neuen State berechnet.
2Wann sollte ich von useState zu useReducer wechseln?
Wenn mehrere State-Variablen bei denselben Ereignissen gemeinsam aktualisiert werden müssen, wenn sich Update-Logik zwischen mehreren Handlern wiederholt, oder wenn der neue State komplex vom vorherigen abhängt.
3Ist useReducer für einfache, unabhängige State-Werte sinnvoll?
Nein. Bei unabhängigen Werten ohne gemeinsame Koordinationslogik fügt useReducer nur unnötige Indirektion hinzu. useState bleibt hier die einfachere und klarere Wahl.
4Was macht einen Reducer zu einer reinen Funktion?
Ein Reducer erzeugt bei gleichem State und gleicher Action immer denselben neuen State, ohne Seiteneffekte wie API-Aufrufe oder Zufallswerte und ohne Abhängigkeit vom React-Rendering-Zyklus.
5Wie teste ich einen Reducer isoliert?
Man ruft den Reducer direkt als normale Funktion mit einem Test-State und einer Test-Action auf und vergleicht das Ergebnis mit dem erwarteten neuen State, ganz ohne Komponenten-Rendering oder renderHook.
6Ist useReducer eine vollwertige Alternative zu Redux?
Für lokale, komponentenbezogene Komplexität ja. Für App-weiten globalen State mit Middleware, Time-Travel-Debugging und DevTools-Integration bleibt Redux oder eine vergleichbare Library die umfangreichere Lösung.
7Wie kombiniere ich useReducer mit mehreren tief verschachtelten Komponenten?
Man stellt state und dispatch über einen Context bereit, sodass jede Komponente im Unterbaum Actions auslösen kann, ohne dass Props über mehrere Ebenen durchgereicht werden müssen.
8Was ist der Unterschied zwischen Action-Typen wie 'SET_X' und 'x/changed'?
'SET_X' bildet oft nur einen direkten Setter-Aufruf ab und bringt keinen strukturellen Vorteil. 'x/changed' beschreibt ein Ereignis in der Oberfläche und lässt den Reducer selbst entscheiden, wie der State darauf reagiert, inklusive Koordination mit anderen Feldern.
9Verschlechtert useReducer mit Context die Rendering-Performance?
Bei vielen Consumern und häufigen Dispatch-Aufrufen kann das Muster an dieselben Grenzen stoßen wie ein einzelner großer Context, da alle Consumer potenziell bei jeder State-Änderung neu rendern, sofern nicht zusätzlich optimiert wird.
10Kann ich useState und useReducer im selben Projekt gemeinsam nutzen?
Ja, das ist üblich. Einfache, unabhängige Zustände bleiben bei useState, während komplexe, zusammenhängende State-Übergänge in einzelnen Komponenten gezielt auf useReducer umgestellt werden.