Startseite / Artikel / Warum Redux oder Zustand zu vielen Entwicklern gehören und nicht zu vielen Lesern

Warum Redux oder Zustand zu vielen Entwicklern gehören und nicht zu vielen Lesern

Die Anzahl der Leser ist kein ausreichender Grund für einen Client-Store. Mehrere Schreiber, die Invarianten wie einen Warenkorb teilen, sind der Fall, in dem Reduzierer, Wiederholungstests und direkte Tests ihre Berechtigung haben.

2370 Wörter

Vier gängige Begründungen für den Einsatz von Redux, Zustand oder ähnlichen Bibliotheken wurden bereits zuvor widerlegt: Prop-Drilling, Updates auf der Client-Seite, automatische Weiterleitung an alle Abonnenten sowie Context als sicherere Standardlösung. React deckt bereits diese Fälle ohne zusätzlichen Store ab.

Keiner dieser Argumente stellte die eigentliche entscheidende Frage bezüglich der Bibliothek. Es geht nicht darum, wie viele Komponenten einen Wert *lesen*, sondern wie viele unabhängige Stellen ihn *ändern* können und ob diese Änderungen danach miteinander konsistent bleiben müssen.

Eine Themenumschaltfunktion hat nur einen Schreiber – eine Steuerung, einen einzigen Aufruf von setTheme. Deshalb war dort eine Bibliothek unbedingt notwendig, egal wie viele Bildschirme das Ergebnis verwendeten. Ein Warenkorb ist eine andere Situation: Mehrere Schreiber wirken in unterschiedliche Richtungen, wofür ein anderes Beispiel erforderlich ist.

Ein Schreiber gegen viele

Ein Warenkorb erscheint auf der Produktkarte als „Zum Warenkorb hinzufügen“, in einem Ausklappmenü als Menge-Steuerung, als Löschlink, als Gutscheinfeld, das die Gesamtbeträge neu berechnet, sowie als Kontrolle zum Leeren des Warenkorbs. Fünf Dateien, fünf Mutationssitze – jeder kann den gleichen Zustand verändern, ohne von den anderen vier zu wissen.

Vergleichen Sie dies mit der Themenflagge: ein Knopf, ein Schreiber. Jeder Leser zeigt einfach den letzten Wert an. Es gibt keine Koordination, denn nur eine Hand dreht das Rad.

Der Warenkorb enthält Invarianten – insgesamt drei. Ein Invarianz ist eine Tatsache, die nach jeder Operation weiterhin gelten muss. Für diesen Warenkorb gilt: Der Gesamtbetrag entspricht stets der Summe aus Preis pro Artikel mal Menge minus der gültige Rabatt; die Menge wird niemals negativ; zwei Artikel teilen sich niemals denselben SKU – das Hinzufügen einer weiteren Einheit eines bestehenden Produkts erhöht die Menge, anstatt eine doppelte Zeile einzufügen.

Fünf Schreiber, drei Fakten, die jeder Schreiber unbedingt beibehalten muss. Das ist das Problem, das Redux, Zustand oder MobX lösen sollen – und es hat nichts damit zu tun, wie viele Komponenten lediglich den Warenkorb lesen.

Was geht schief, wenn fünf unabhängige Schreiber dieselben drei Fakten ohne jegliche Kontrolle ändern?

Was zusammenbricht, wenn es keinen disziplinierten Ort gibt

Stellen Sie sich vor, drei dieser fünf Mutationsspunkte ändern direkt das Warenkorb-Objekt – jeweils im Stil, den der Eigentümer der Datei wählen würde:

// components/AddToCartButton.jsx
function addItem(item) {
  cart.items.push(item);
  cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/QuantityStepper.jsx
function changeQuantity(sku, newQty) {
  const item = cart.items.find(i => i.sku === sku);
  item.quantity = newQty;
  cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/CouponInput.jsx
function applyCoupon(code) {
  cart.discount = getDiscountFor(code);
}

getDiscountFor ist eine einfache Abfrage: Eingabe des Codes, Ausgabe der Rabattnummer. Sowohl AddToCartButton.jsx als auch QuantityStepper.jsx berechneten erneut cart.total. CouponInput.jsx tat das nicht. Die Invarianz „Gesamtsumme gleich Summe minus Rabatt“ wurde gebrochen, und die Sprachumgebung unternahm nichts – es gab keine Regel, die dafür verantwortlich war. Es handelte sich um eine Konvention, nach der drei Dateien befolgt werden sollten; eine davon vergaß es.

Ein Reducer beseitigt dieses Vertrauen, indem er jede Änderung über eine einzige Funktion abwickelt. Ein Reducer nimmt den aktuellen Zustand sowie eine Aktion (ein einfaches Objekt, das beschreibt, was passiert ist) entgegen und gibt einen völlig neuen Zustand zurück. Er ändert das alte Objekt niemals direkt vor Ort.

// store/cartReducer.js
function computeTotal(items, discount) {
  const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  return subtotal * (1 - discount);
}

export function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const items = [...state.items, action.item];
      return { ...state, items, total: computeTotal(items, state.discount) };
    }
    case 'CHANGE_QUANTITY': {
      const items = state.items.map(i =>
        i.sku === action.sku ? { ...i, quantity: action.qty } : i
      );
      return { ...state, items, total: computeTotal(items, state.discount) };
    }
    case 'APPLY_COUPON': {
      const discount = getDiscountFor(action.code);
      return { ...state, discount, total: computeTotal(state.items, discount) };
    }
  }
}

computeTotal wird in jedem Switch-Ableger ausgeführt – nicht, weil sich jeder Autor daran erinnert hat, sondern weil das Überspringen mit nur einer Funktion und einem Switch unpraktisch wäre. Die drei ehemaligen direkten Schreiber beschreiben nun Ereignisse anstelle davon, sie auszuführen:

// components/AddToCartButton.jsx
import { useCartDispatch } from '../store/CartContext';

function AddToCartButton({ item }) {
  const dispatch = useCartDispatch();
  return <button onClick={() => dispatch({ type: 'ADD_ITEM', item })}>Add to Cart</button>;
}
export default AddToCartButton;
// components/QuantityStepper.jsx
import { useCartDispatch } from '../store/CartContext';

function QuantityStepper({ sku, qty }) {
  const dispatch = useCartDispatch();
  return (
    <input
      type="number"
      value={qty}
      onChange={e => dispatch({ type: 'CHANGE_QUANTITY', sku, qty: Number(e.target.value) })}
    />
  );
}
export default QuantityStepper;

dispatch stammt aus einer kleinen Datei CartContext.jsx, die useReducer (den eingebauten Hook, der Zustand sowie Dispatch zurückgibt) mit Context verbindet, damit jede Komponente auf diesen Dispatch zugreifen kann. Weder die Schaltflächen noch der Schrittweiser schreiben noch cart.total = .... Keiner von ihnen muss wissen, dass computeTotal existiert.

Die Lesezugriffe bleiben offen. Komponenten können weiterhin cart.total zur Anzeige des Preises, in Kassenzusammenfassungen oder für Benachrichtigungen über ungespeicherte Artikel abrufen. Schreibvorgänge folgen nur einem Weg: Eine Aktion wird ausgelöst, wodurch der Reducer den nächsten Zustand erzeugt. Die Invarianz liegt an dieser Engstelle und nicht bei demjenigen, der sie aufruft.

Sprechen wir präzise: Eine einzige Schreibfunktion macht die Regel nicht korrekt – sie sorgt lediglich für konsistente Anwendung. Wenn computeTotal selbst den Rabatt vergisst, würde jeder Schreibweg jedes Mal denselben falschen Gesamtbetrag ergeben. Das ist immer noch besser als die zerstreute Variante: Ein einheitlicher Fehler lässt sich leicht isolieren. Ein Fehler, der nur dann auftritt, wenn eine Datei eine Zeile vergisst, ist viel schwieriger zu finden, denn die korrekten und fehlerhaften Wege sehen bis zum Auftreten des Fehlers identisch aus.

Wenn der Gutscheinpfad die Neuberechnung vergisst, kann die Benutzeroberfläche zunächst noch in Ordnung aussehen – bis später eine Änderung der Menge zu einer Neuberechnung führt oder bis die Bestellabwicklung einen Gesamtbetrag anzeigt, der nicht mehr mit den Einzelpositionen übereinstimmt. Genau dieses intermittierende Verhalten ist der Grund, warum Konventionen versagen: Der fehlerhafte Pfad kommt selten genug vor, um zufällige Klicks zu überstehen, und häufig genug, um Probleme zu verursachen.

Durch die Zentralisierung der Regel entfällt zwar nicht die Notwendigkeit für eine sorgfältige computeTotal-Logik, doch sie stellt sicher, dass jeder Schreibpfad dieselbe Funktion verwendet. Dadurch bleibt ein falsches Formelwerk konsistent und somit auffindbar. Verstreute Updates verbergen denselben Fehler hinter dem Eindruck, „im Großen und Ganzen funktioniert es“.

Ein einziger, diszipliniert strukturierter Schreibpfad ist an sich bereits wertvoll. Was bringt er zusätzlich, wenn später etwas kaputtgeht?

Jede Aktion wird zu einem wiederholbaren Schnappschuss

Zwei übereinander angeordnete Reducer-Eigenschaften ergeben etwas Stärkeres als eine Logdatei.

Zunächst sind Aktionen serialisierbar: reines JSON ohne Funktionen, Klasseninstanzen oder versteckte Verweise – nur { type: 'APPLY_COUPON', code: 'SAVE10' }. Zweitens ist cartReducer rein: derselbe Zustand zusammen mit derselben Aktion erzeugt immer denselben nächsten Zustand, ohne externe Lesevorgänge.

Zusammen führt das Wiederabspielen einer Sequenz vom selben Start immer zum selben Ende. Dieser Determinismus ist es, den Redux DevTools tatsächlich verwendet. Es gibt lediglich an, dass etwas geschehen ist; stattdessen speichert es die Aktionen als Daten und berechnet nach jedem ausgewählten Schritt erneut genau denselben Zustandsbild, wenn man darauf klickt.

Nehmen wir den früheren Fehler – der Gesamtbetrag ist völlig falsch, nachdem ein Gutschein angewendet und anschließend ein Artikel entfernt wurde. Mit den DevTools geöffnet zeigt die Sitzung ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. Nach APPLY_COUPON sieht der Gesamtbetrag richtig aus; nach REMOVE_ITEM nicht. Der Fehler hat eine konkrete Ursache – die REMOVE_ITEM-Schleife – ohne dass dazu console.log eingefügt oder die Benutzersitzung manuell nachgespielt werden muss.

Dieser Vorteil ist nicht in allen Bibliotheken gleich. Redux DevTools ist die ausgereifte Originallösung. Zustand’s devtools-Middleware integriert set() in dieselbe Erweiterung, sodass Aktualisierungen als benannte Aktionen angezeigt werden. MobX mutiert in der Regel die beobachtbaren Werte direkt über Proxy-Strukturen statt über eine Abfolge serialisierbarer Aktionen; daher legt seine Tooling-Lösung mehr Wert auf Reaktionsgraphen – also welche Berechnungen erneut ausgeführt wurden und warum – als auf ein vollständiges Zeitreise-Protokoll.

Replay benötigt eine deterministische Funktion, die immer dieselben Eingaben in dieselben Ausgaben umwandelt. Was bringt das außerhalb der Browsererweiterung noch?

Ein Reducer ist einfach nur eine Funktion, die aufgerufen werden kann

cartReducer(startState, action) ist ein gewöhnlicher Aufruf: Eingabeparameter kommen herein, der gesamte nächste Zustand kommt heraus. Um ihn zu testen, sind weder ein Browser, Klicks noch renderierte Komponenten erforderlich.

// store/cartReducer.test.js
import { cartReducer } from './cartReducer';

test('APPLY_COUPON recomputes total', () => {
  const startState = {
    items: [{ sku: 'A1', price: 20, quantity: 2 }],
    discount: 0,
    total: 40,
  };
  const nextState = cartReducer(startState, { type: 'APPLY_COUPON', code: 'SAVE10' });
  expect(nextState.discount).toBe(0.10);
  expect(nextState.total).toBe(36); // 40 minus 10 percent
});

Der Test wird ohne Rendering-Bibliothek in Millisekunden abgeschlossen. Er erfasst den früheren Fehler direkt: Wenn APPLY_COUPON computeTotal übersprungen hätte, wäre nextState.total weiterhin 40 gewesen, und die Überprüfung würde an der fehlerhaften Abzweigung fehlschlagen, anstatt auf ein unklares UI-Problem hinzudeuten.

Die gleiche Logik wie ein useState-Setter innerhalb eines Klick-Handlers lässt sich nicht auf diese Weise testen – und der Grund liegt nicht bei JSX:

// hooks/useCoupon.js
import { useState } from 'react';

function useCoupon() {
  const [discount, setDiscount] = useState(0);
  const applyCoupon = code => setDiscount(getDiscountFor(code));
  return { discount, applyCoupon };
}
export default useCoupon;

Der Aufruf von useCoupon() aus einem einfachen Test führt zu einem Fehler. Hooks wie useState funktionieren nur während eines React-Renderings (oder innerhalb eines anderen Hooks, der während des Renderings aufgerufen wird) und werden im Zusammenhang mit dieser Komponenteninstanz verfolgt. Das sind die Regeln der Hooks: Bedingungslose Aufrufe in derselben Reihenfolge bei jedem Rendering, denn React vergleicht den Hook-Zustand anhand der Aufrufreihenfolge und nicht nach Name. Wenn ein Hook bei einigen Renderings übersprungen wird, kommt es zu einer Asynchronisierung der Verfolgung.

applyCoupon fällt ebenfalls darin zurück, keinen nützlichen Zustand auf die gleiche Weise zurückzugeben wie cartReducer. setDiscount gibt undefined zurück. Es plant eine erneute Darstellung; der neue Wert erscheint erst bei der nächsten Ausführung des Komponentenkörpers. Es gibt keinen Rückgabewert, den man überprüfen könnte – das Verfahren besteht darin, React um eine erneute Darstellung zu bitten, nicht darin, einen Wert zu berechnen und zurückzugeben.

Um es zu testen, muss man etwas darstellen und prüfen, was auf dem Bildschirm angezeigt wird:

// CartSummary.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import CartSummary from './CartSummary';

test('applying coupon updates the displayed total', () => {
  render(<CartSummary />);
  fireEvent.click(screen.getByText('Apply SAVE10'));
  expect(screen.getByTestId('total')).toHaveTextContent('36');
});

Dieselbe zu prüfende Situation – der Gesamtbetrag nach Anwendung eines Gutscheins – doch die Überprüfung bezieht sich auf den angezeigten Text nach einer vollständigen Darstellung und einem simulierten Klick.

Ein Mittelweg ist renderHook aus der React Testing Library: Man setzt einen Hook ohne JSX oder Klicks ein und inspiziert anschließend result.current. Dafür wird weiterhin Reacts Test-Renderer unter act benötigt, der Updates und Effekte vor den Assertionen ausführt. Ein Scaffold bleibt weiterhin notwendig, da der Zustand des Hooks weiterhin innerhalb einer montierten (auch minimalen) Instanz vorhanden ist. cartReducer benötigte all das nicht, da er niemals mit einer Komponente verknüpft war.

Testbarkeit hängt vom Kopplungsgrad ab. Wie sieht dieselbe Idee aus, wenn man entscheidet, wo ein Store endet und ein anderer beginnt?

Wo die Grenze tatsächlich verläuft

Die Anfangsinvarianten beantworten außerdem eine andere Frage: wo ein Store aufhören und der nächste beginnen sollte.

Die items, discount und total des Warenkorbs gehören zusammen, weil sie miteinander verknüpft sind – eine Änderung kann Fakten, die von den anderen abhängen, ungültig machen. theme gehört nicht in diesen Reducer: Nichts bezüglich von theme bestimmt cart.total, und nichts am Warenkorb bestimmt theme. Es handelt sich um unabhängigen Zustand, der lediglich in einer Anwendung zusammenexistiert.

Ein echtes Produkt entwickelt in der Regel mehrere solcher Regelsammlungen, wobei jede von den anderen nichts weiß:

cartStore     → items, discount, total
authStore     → user, session, permissions
themeStore    → theme
notifyStore   → toasts, unread count

In Redux zeigt sich das als Slices – ein Reducer pro Teil des Baums –, die unter einem Root kombiniert werden, ohne dass ihre Logik zusammengeführt wird. In Zustand sind es separate create()-Aufrufe (useCartStore, useAuthStore, …). In MobX sind es separate beobachtbare Klassen mit eigenen Aktionen und Invarianten, ohne gemeinsamen Reducer.

Ein Komponent kann bei einer einzigen Darstellung mehrere Stores lesen. Eine Warenkorbzusammenfassung könnte beispielsweise cartStore.total und authStore.user.currency lesen, um Geldbeträge zu formatieren. Lesen ist frei miteinander kombinierbar. Schreiben hingegen darf nicht überschreiten: Der Warenkorb-Reducer sollte keinen Zugriff auf den Authenzitätszustand haben und umgekehrt.

Falls der Änderung eines Stores eine Änderung eines anderen erforderlich ist, um einen Zustand aufrechtzuerhalten – beispielsweise zwingt ein Wechsel der Währung zu einer Neuberechnung des Warenkorbgesamtbetrags – wurde die Grenze falsch festgelegt. Entweder gehören die Komponenten zu einem koordinierten Store, oder es ist eine explizite Brücke erforderlich, deren Aufgabe darin besteht, sie synchron zu halten – anstatt einer impliziten Abhängigkeit, die von niemandem dokumentiert wird.

Die Wahl der Bibliothek ist weiterhin wichtig für Teamkonventionen, Middleware und die Größe des Ökosystems, doch das ist sekundär. Der primäre Faktor ist strukturell: mehrere Entwickler sowie gemeinsam genutzte Invarianten. Ohne diese Struktur decken Reacts eigene Context-, Reducer- und Props-Mechanismen bereits die meisten Fälle ab, in denen globale Zugriffe erforderlich sind. Mit dieser Struktur sichert ein spezieller Store – wie Redux Toolkit, Zustand, MobX oder ein sorgfältig geteilter useReducer – seine Relevanz, indem er die Regeln an einem Ort zentral verwaltet.

Teams erkennen diese Grenze oft erst spät, nachdem bereits fünf Stellen für Änderungen in Prozessen wie dem Warenkorb, der Buchungsabwicklung oder der Berechtigungsmatrix entstanden sind. Die Anpassung eines Reducers ist immer noch günstiger als das Debuggen von unregelmäßigen Ergebnissen. Je früher die Invarianten im Code benannt werden, desto weniger muss die Benutzeroberfläche durch ad-hoc-Lösungen ausgleichen.

Falls eine Funktion nur einen Autor hat und keine interdisziplinären Regeln vorsieht, sollte sie lokal gehalten werden. Wenn es mehrere Autoren gibt und Regeln gelten müssen, die für jeden Autor bestehen bleiben, dann benötigen diese Eingaben ein gemeinsames System.

Die eigentliche Antwort

Bisherige Darstellungen zeigten, dass allein die Anzahl der Leser niemals eine Bibliothek rechtfertigt – React kümmert sich bereits um solche Fälle. Dieser Artikel befasst sich mit der anderen Hälfte: Wie viele unabhängige Stellen schreiben können und ob diese Eingaben dieselben Fakten beibehalten müssen, ist die eigentliche Prüfung.

Ein Theme-Flag versagt bei diesem Test überall: ein Autor, keine cross-field-invariante Eigenschaft, nichts, was koordiniert werden könnte. Ein Wagen besteht überall sofort den Test: fünf Autoren, drei Fakten, die jeder von ihnen ändern kann, ein Reduktor, der „Fünf Personen müssen sich an die Regel erinnern“ in „Die Regel gilt bedingungslos“ umwandelt, sowie zwei nützliche Nebeneffekte – Debugging als wiedergabefähige Zeitachse anstelle von Ratenspiel und Tests, die eine Funktion aufrufen statt einen Bildschirm anzuzeigen, um eine Zahl abzurufen.

Das ist die entscheidende Kriterium. Nicht die Anzahl der Komponenten. Die Anzahl der Autoren und das, was bei allen von ihnen gelten muss.

Anders ausgedrückt: Bibliotheken für den Client-Zustand dienen dazu, Schreiber zu koordinieren, nicht zur Verteilung von Lesern. Die Verteilung der Leser ist Aufgabe von React. Die Koordination der Schreiber – sowie die Invarianten, die diese Schreiber einhalten müssen – ist der Punkt, an dem Redux, Zustand, MobX oder eine ähnliche Store-Lösung zur richtigen Wahl wird und nicht zu einer Gewohnheit.