Redux als Protokoll für UI-Änderungen
Der Store ist der langweiligste Teil von Redux – wertvoll ist das Nachrichtenprotokoll aus benannten, serialisierbaren Actions, das UI-Änderungen nachvollziehbar, testbar und diskutierbar macht.
In der Netzwerktechnik ist ein Protokoll keine Software, sondern eine Vereinbarung: Es legt fest, welche Nachrichten zwischen Parteien fließen dürfen, wie jede Nachricht aufgebaut ist und was sie bedeutet. TCP schreibt niemandem vor, welches Programm an den Endpunkten läuft – es bestimmt nur die Form der Pakete und die Regeln des Austauschs. Genau dieser Begriff, auf eine Benutzeroberfläche übertragen, beschreibt Redux treffender als die übliche Kurzformel.
Die lautet nämlich: Redux sei ein globaler Zustandsbehälter für React-Anwendungen. Das ist nicht falsch, aber es zeigt auf den langweiligsten Teil. Ein Objekt, das Anwendungszustand hält, hat jede Anwendung – notfalls als window.appState, und damit ist noch gar nichts gewonnen. Interessant an Redux ist die Vereinbarung darüber, wie sich dieses Objekt ändern darf: ausschließlich über benannte, serialisierbare Nachrichten. Redux ist weniger ein Behälter als ein Protokoll für Änderungen – zwischen den Teilen der Oberfläche und, was gern übersehen wird, über die Zeit hinweg. Wer heute eine Action dispatcht, redet auch mit dem Kollegen, der in drei Monaten das Action-Log eines Fehlerberichts liest.
Drei Bausteine, sehr ungleich verteilt
Redux besteht aus drei Teilen: dem Store, der den Zustand hält, den Actions, die Änderungen beschreiben, und den Reducern, die aus altem Zustand und Action den neuen Zustand berechnen. Der Store selbst ist erstaunlich unspektakulär – ein paar Dutzend Zeilen um ein Objekt, eine dispatch-Funktion und eine Subscriber-Liste. Wer den Quelltext von createStore liest, ist nach einer Tasse Kaffee durch.
Der Wert steckt in dem, was die beiden anderen Bausteine erzwingen. Eine Action ist ein einfaches Objekt mit einem type und Nutzdaten:
// actions.js
export const ORDER_SUBMITTED = 'ORDER_SUBMITTED';
export const ORDER_ACCEPTED = 'ORDER_ACCEPTED';
export const ORDER_REJECTED = 'ORDER_REJECTED';
export const submitOrder = order => ({
type: ORDER_SUBMITTED,
payload: { orderId: order.id, items: order.items }
});
Kein Funktionszeiger, keine Klasseninstanz, kein Verweis auf eine Komponente. Nur Daten, die sich als JSON aufschreiben lassen. Das wirkt wie eine Einschränkung – und ist genau als solche gemeint. Eine Nachricht, die sich serialisieren lässt, lässt sich auch aufzeichnen, versenden, vergleichen und Jahre später noch lesen. Ein Methodenaufruf kann das alles nicht.
Der Reducer ist die zweite Hälfte der Vereinbarung: eine reine Funktion, die niemals selbst etwas auslöst, sondern nur beantwortet, was eine Nachricht für den Zustand bedeutet.
// ordersReducer.js
import { ORDER_SUBMITTED, ORDER_ACCEPTED } from './actions';
const initialState = { pending: [], accepted: [] };
export default function ordersReducer(state = initialState, action) {
switch (action.type) {
case ORDER_SUBMITTED:
return { ...state, pending: [...state.pending, action.payload.orderId] };
case ORDER_ACCEPTED:
return {
...state,
pending: state.pending.filter(id => id !== action.payload.orderId),
accepted: [...state.accepted, action.payload.orderId]
};
default:
return state;
}
}
Aus dieser Trennung – Nachricht hier, Bedeutung dort – folgt fast alles, was Teams an Redux schätzen. Meist wird es unter „Vorhersagbarkeit" verbucht. Mir hilft eine andere Sortierung:
- Jede Änderung lässt sich aufzeichnen und wieder abspielen, weil sie als Datenobjekt vorliegt.
- Jede Änderung lässt sich ohne Browser testen, weil der Reducer eine reine Funktion ist.
- Jede Änderung hat einen Namen, über den man im Code-Review und mit der Fachseite reden kann.
- Jede Änderung lässt sich von außen beobachten, ohne die UI anzufassen.
Das sind keine vier Features. Das ist viermal dieselbe Eigenschaft: Es gibt ein Protokoll.
Zeitreise ist keine Zauberei
Die Redux DevTools führen das am eindrucksvollsten vor. Jede Action erscheint dort als Zeile in einem Log, und weil der Zustand nur aus der Faltung aller bisherigen Actions über die Reducer entsteht, kann man im Log beliebig zurückspringen – die Oberfläche folgt. Beim ersten Vorführen wirkt das wie ein Trick. Tatsächlich ist es die banale Konsequenz aus dem Protokollgedanken: Wer alle Nachrichten kennt und eine deterministische Funktion darauf anwendet, kann jeden Zwischenstand rekonstruieren. Netzwerkleute kennen das seit Ewigkeiten – ein Paketmitschnitt plus Zustandsautomat ergibt die komplette Sitzung.
In einem Projekt in diesem Sommer hat uns genau das einen zähen Fehler erspart. Ein Anwender meldete einen Zustand, den niemand nachstellen konnte: eine Bestellung, die gleichzeitig als offen und als bestätigt angezeigt wurde. Statt Vermutungen zu sammeln, haben wir uns den exportierten Action-Verlauf schicken lassen und ihn lokal wieder eingespielt. Die fehlerhafte Reihenfolge – ORDER_ACCEPTED traf vor ORDER_SUBMITTED ein, weil zwei Requests sich überholt hatten – stand nach fünf Minuten schwarz auf weiß im Log. Mit verteilten setState-Aufrufen wäre dieselbe Diagnose ein Nachmittag mit Debugger und Glück gewesen.
Auch das Testen profitiert von der Nachrichtenform, und zwar ohne jede Test-Infrastruktur:
// ordersReducer.test.js
import ordersReducer from './ordersReducer';
import { submitOrder } from './actions';
it('moves a submitted order into pending', () => {
const state = ordersReducer(undefined, submitOrder({ id: '42', items: [] }));
expect(state.pending).toEqual(['42']);
});
Kein DOM, kein Mock eines Stores, kein Rendern. Eingabe, Ausgabe, fertig. Reducer-Tests gehören zu den wenigen Frontend-Tests, die ich noch nie flaky gesehen habe.
Ereignisse statt Befehle
Genau an dieser Stelle geht der Wert allerdings auch am schnellsten verloren – nicht durch die Bibliothek, sondern durch den Zuschnitt der Nachrichten. In einem Code-Review im Frühjahr stand ein Reducer vor mir, dessen Action-Liste so aussah: SET_LOADING_TRUE, SET_LOADING_FALSE, SET_ORDERS, SET_ERROR, RESET_ERROR. Formal alles korrekt: benannte, serialisierbare Objekte, reine Reducer, DevTools funktionieren. Trotzdem war das Protokoll wertlos, denn die Nachrichten beschrieben nicht, was passiert ist, sondern welchen Feldwert der Absender gern hätte. Das ist kein Ereignisprotokoll, das sind entfernte Zuweisungen – setState mit Umweg und mehr Tipparbeit.
Der Unterschied wird im direkten Vergleich deutlich:
// commands: the sender prescribes how state must change
dispatch({ type: 'SET_LOADING_TRUE' });
dispatch({ type: 'SET_ORDERS', payload: orders });
dispatch({ type: 'SET_LOADING_FALSE' });
// events: the sender reports what happened in the domain
dispatch({ type: 'ORDERS_REQUESTED' });
dispatch({ type: 'ORDERS_LOADED', payload: { orders } });
dispatch({ type: 'ORDERS_LOADING_FAILED', payload: { error } });
Die Ereignisvariante hat drei Eigenschaften, die der Befehlsvariante fehlen. Erstens trägt sie Fachsprache in den Code: ORDER_SUBMITTED ist ein Satz, den auch die Produktverantwortliche versteht, SET_LOADING_TRUE versteht nur der Autor der Komponente. Im Review reden wir dann über die Frage „Darf eine abgelehnte Bestellung erneut eingereicht werden?" statt über Boolean-Choreografie. Zweitens ist ein Ereignis vergangen und damit unstrittig – es wird berichtet, nicht angeordnet. Der Absender muss nicht wissen, wer zuhört. Drittens skaliert genau deshalb der Empfängerkreis: Wenn später ein zweiter Reducer die Zahl offener Bestellungen für einen Badge zählen soll, abonniert er ORDER_SUBMITTED einfach mit. Bei SET_ORDERS müsste der Absender umgebaut werden. Ein Befehl kennt seinen Empfänger, ein Ereignis kennt nur seinen Anlass – im Protokollbild ist das der Unterschied zwischen einem RPC-Aufruf und einem Broadcast.
Seitdem lese ich Action-Dateien wie ein Nachrichtenverzeichnis. Steht dort ein Vokabular der Fachdomäne in Vergangenheitsform, trägt das Protokoll. Steht dort eine Liste von Settern, hat das Team einen zweiten setState-Mechanismus gebaut, nur umständlicher.
sequenceDiagram participant V as View participant M as Middleware participant R as Reducer participant S as Store V->>M: dispatch(ORDER_SUBMITTED) Note over M: Abhörpunkt:<br/>Logging, Analytics, DevTools M->>R: action (unverändert) R->>S: neuer Zustand S-->>V: notify + render
Middleware: das Protokoll abhören
Wer Actions als Nachrichten ernst nimmt, bekommt eine zweite Fähigkeit geschenkt, die bei der Behälter-Sichtweise unsichtbar bleibt: Man kann den Nachrichtenstrom anzapfen. Genau das ist Middleware – eine Funktion, die zwischen dispatch und Reducer sitzt und jede Nachricht sieht, bevor sie Bedeutung bekommt.
// analyticsMiddleware.js
const analyticsMiddleware = store => next => action => {
if (action.type.startsWith('ORDER_')) {
trackEvent(action.type, action.payload);
}
return next(action);
};
Die dreifach gestaffelte Signatur ist gewöhnungsbedürftig, aber schlicht angewandtes Currying – wie solche Funktionsketten entstehen, habe ich vor ein paar Wochen in JavaScript-Patterns: Module, Higher-Order-Functions, Currying auseinandergenommen. Wichtiger als die Form ist die Konsequenz: Analytics braucht keinen einzigen Aufruf in einer Komponente. In dem Bestell-Projekt hängt die Produktauswertung komplett an dieser einen Datei. Als eine neue Kennzahl gewünscht wurde – wie oft eine Ablehnung auf eine Einreichung folgt – war das eine Änderung in der Middleware, nicht in dreißig Komponenten. Dasselbe gilt für Logging im Fehlerfall: Die letzten fünfzig Actions in einen Ringpuffer zu schreiben und an den Error-Report zu hängen, kostet zwanzig Zeilen.
Auch die Async-Frage ist am Ende eine Protokollfrage. Ob redux-thunk für die einfachen Fälle oder redux-saga und redux-observable, wenn Abläufe orchestriert werden müssen – alle drei sind Middleware, und alle drei münden am Ende wieder in gewöhnliche Actions. Der asynchrone Teil ändert die Sprache nicht, er spricht sie nur zeitversetzt. Wie sich Push- und Pull-Datenflüsse dabei grundsätzlich unterscheiden, steht in RxJS: Push und Pull – Redux ist konsequent auf der Push-Seite unterwegs.
Die Grenze: Nicht jede Änderung verdient eine Nachricht
So nützlich das Protokoll ist – es hat einen Preis pro Nachricht: eine Konstante, ein Action-Creator, ein Reducer-Zweig, im Zweifel ein Test. Diesen Preis sollte nur zahlen, was ihn wert ist. Ob ein Dropdown gerade offen ist, welcher Tab aktiv ist, was in einem Eingabefeld steht, bevor es abgeschickt wird – das ist lokaler UI-Zustand, und der gehört in die Komponente, in ein schlichtes setState. Niemand will im Action-Log dreißig INPUT_CHANGED zwischen zwei fachlichen Ereignissen lesen; das Protokoll verrauscht, und die Zeitreise wird zur Diashow.
Dan Abramov selbst hat das im vergangenen Jahr in „You Might Not Need Redux" deutlich aufgeschrieben, und der Text ist keine Distanzierung, sondern eine Preisliste: Die Vorteile von Redux entstehen genau aus seinen Einschränkungen, also lohnt es sich nur dort, wo man die Vorteile auch einlöst. Meine Prüffragen, bevor Zustand in den Store wandert:
- Lesen mehrere, im Baum weit voneinander entfernte Komponenten diesen Zustand?
- Hat die Änderung einen fachlichen Namen, der in einem Fehlerbericht etwas erklären würde?
- Interessiert die Historie dieser Änderungen irgendwen – Support, Analyse, Debugging?
Dreimal Nein heißt: Komponente. React kann lokalen Zustand seit jeher selbst, und daran hat auch das gerade erschienene React 16 nichts geändert – der neue Fiber-Kern rendert anders, aber setState bleibt setState. Die beiden Mechanismen konkurrieren nicht, sie bedienen verschiedene Fragen.
flowchart TD
A[Zustand entsteht] --> B{Lesen entfernte<br/>Komponenten mit?}
B -- nein --> L[Lokal:<br/>setState in der Komponente]
B -- ja --> C{Fachlicher Name?<br/>Historie relevant?}
C -- nein --> L
C -- ja --> S[Store:<br/>Action + Reducer]
Im Repository introduction-react, in dem ich seit dem Frühjahr React-Themen für Vorträge und Beispiele sammle, ist der Redux-Teil aus genau diesem Grund bewusst klein: eine überschaubare Beispiel-App, in der das Nachrichtenvokabular auf eine Handvoll fachlicher Ereignisse begrenzt bleibt. Nicht, weil mehr nicht ginge – sondern weil der Unterschied zwischen Protokoll und Behälter an einem kleinen, sauberen Vokabular besser sichtbar wird als an einem großen, verrauschten.
Was ich daraus mitnehme
Mir ist mit der Zeit aufgefallen, dass die Teams, die mit Redux glücklich sind, und die, die darüber fluchen, gar nicht verschiedene Bibliotheken benutzen – sie benutzen verschiedene Protokolle. Die einen haben ein Vokabular fachlicher Ereignisse, das man laut vorlesen kann; ihr Action-Log liest sich wie ein Ablaufbericht der Anwendung. Die anderen haben Setter mit Großbuchstaben und wundern sich, dass der versprochene Nutzen ausbleibt: Die DevTools zeigen Rauschen, die Tests prüfen Zuweisungen, und im Review gibt es nichts zu diskutieren, weil SET_VALUE keine fachliche Aussage enthält, der man widersprechen könnte.
Deshalb halte ich den Store inzwischen für ein Implementierungsdetail und das Protokoll für die eigentliche Architekturentscheidung. Die drei Fragen, die den Unterschied machen, haben mit der Bibliothek wenig zu tun: Welche Ereignisse gibt es in dieser Anwendung? Wie heißen sie in der Sprache der Fachdomäne? Und wer darf sie auslösen? Wer diese Fragen beantwortet hat, bekommt von Redux Nachvollziehbarkeit, Testbarkeit und eine gemeinsame Sprache fast geschenkt. Wer sie überspringt, bekommt einen globalen Zustandsbehälter mit Zeremonie – und hätte dann tatsächlich kein Redux gebraucht.
Sollte Redux irgendwann von etwas anderem abgelöst werden, bleibt die Übung trotzdem wertvoll. Ein Protokoll benannter, serialisierbarer Änderungsnachrichten ist keine Erfindung dieser Bibliothek – die Netzwerktechnik lebt seit Jahrzehnten davon, und Event-getriebene Systeme auf dem Server ebenso. Redux hat diese Disziplin nur an eine Stelle getragen, an der sie vorher selten war: mitten in die UI.
Weiterführende Quellen
- MikeBild/introduction-react – Repository mit React-Beispielen, inklusive Flux/Redux-Teil und Beispiel-App
- Redux-Dokumentation: Motivation – warum Mutation plus Asynchronität das eigentliche Problem ist
- Dan Abramov: You Might Not Need Redux – die Preisliste zu den Einschränkungen, vom Autor selbst
- React v16.0 Release-Ankündigung – der frische Fiber-Kern, der am Datenflussmodell nichts ändert
Kommentare