24 Redux - Vorhersagbare State-Verwaltung für React-Anwendungen

Redux ist eine der einflussreichsten Bibliotheken im React-Ökosystem und hat die Art, wie wir über State-Management in JavaScript-Anwendungen denken, grundlegend verändert. Während React selbst bereits mächtige Werkzeuge für lokale Zustandsverwaltung durch useState und useReducer bietet, stößt man bei wachsender Anwendungskomplexität schnell an deren Grenzen. Redux löst diese Herausforderungen durch eine klare, vorhersagbare Architektur, die auch in großen Teams und komplexen Anwendungen skaliert.

Die Entwicklung von Redux durch Dan Abramov wurde stark von funktionaler Programmierung und dem Flux-Architekturpattern inspiriert. Anders als andere State-Management-Lösungen seiner Zeit setzte Redux auf drei fundamentale Prinzipien, die gemeinsam eine außergewöhnlich robuste und debugbare Anwendungsarchitektur ermöglichen. Diese Prinzipien mögen zunächst restriktiv erscheinen, erweisen sich aber als Befreiung von vielen klassischen Problemen der Anwendungsentwicklung.

24.1 Die drei Grundprinzipien von Redux

Das erste Prinzip von Redux besagt, dass der gesamte State einer Anwendung in einem einzigen Store gespeichert wird - der sogenannten “Single Source of Truth”. Diese Zentralisierung bringt erhebliche Vorteile mit sich. Debugging wird einfacher, da man immer genau weiß, wo sich welche Daten befinden. Die Persistierung von Anwendungszuständen wird trivial, da der gesamte State serialisierbar in einem Objekt vorliegt. Auch die serverseitige Renderung profitiert von dieser Struktur, da der komplette Client-State einfach an den Server übertragen werden kann.

Das zweite Prinzip definiert State als read-only. Direkte Änderungen am State sind in Redux nicht möglich und nicht erlaubt. Stattdessen beschreiben Actions als einfache JavaScript-Objekte, welche Änderungen vorgenommen werden sollen. Dieses Prinzip eliminiert eine der häufigsten Fehlerquellen in JavaScript-Anwendungen: unbeabsichtigte State-Mutationen. Jede Änderung am State muss explizit durch eine Action beschrieben werden, was zu einer natürlichen Dokumentation des Anwendungsverhaltens führt.

Das dritte Prinzip besagt, dass Änderungen durch pure Functions, sogenannte Reducers, beschrieben werden. Ein Reducer nimmt den aktuellen State und eine Action entgegen und gibt einen neuen State zurück, ohne den ursprünglichen State zu verändern. Pure Functions sind vorhersagbar, testbar und haben keine Seiteneffekte - Eigenschaften, die Redux seine charakteristische Robustheit verleihen.

24.2 Action Types - Die Grundlage typsicherer Redux-Entwicklung

Action Types bilden das Rückgrat einer gut strukturierten Redux-Anwendung. Obwohl man Actions technisch mit beliebigen Strings definieren könnte, führt dies schnell zu Fehlern und macht die Anwendung schwer wartbar. Stattdessen definiert man Action Types als Konstanten, idealerweise in einem zentralen Objekt oder als separate Konstanten.

const ActionTypes = {
  ADD_TODO: 'ADD_TODO',
  TOGGLE_TODO: 'TOGGLE_TODO',
  REMOVE_TODO: 'REMOVE_TODO',
  SET_FILTER: 'SET_FILTER'
} as const;

Die Verwendung von TypeScript’s as const assertion sorgt dafür, dass diese Werte nicht als generische Strings, sondern als spezifische Literal-Types behandelt werden. Dies ermöglicht präzise Typisierung und verhindert Tippfehler zur Compile-Zeit. Ein häufiger Fehler besteht darin, Action Types direkt als Strings in Reducers zu verwenden, was bei Refactoring zu schwer auffindbaren Bugs führen kann.

Die Benennung von Action Types folgt konventionell dem Pattern NOUN_VERB oder DOMAIN/ACTION. Gute Action Types beschreiben klar und eindeutig, was passiert ist, nicht was passieren soll. Sie sollten vergangene Ereignisse ausdrücken: USER_LOGGED_IN statt LOG_IN_USER. Diese Konvention macht den Action-Stream lesbar und hilft beim Debugging.

24.3 Actions und Action Creators - Die API des State-Changes

Actions sind einfache JavaScript-Objekte, die mindestens eine type-Eigenschaft haben und optional weitere Daten im payload enthalten. Während man Actions direkt erstellen könnte, haben sich Action Creators als Best Practice etabliert. Action Creators sind Funktionen, die Actions erstellen und dabei Konsistenz und Typsicherheit gewährleisten.

interface AddTodoAction {
  type: typeof ActionTypes.ADD_TODO;
  payload: { text: string };
}

const addTodo = (text: string): AddTodoAction => ({
  type: ActionTypes.ADD_TODO,
  payload: { text }
});

Action Creators kapseln die Action-Erstellung und bieten einen einzigen Ort für Validierung, Normalisierung oder zusätzliche Metadaten. Sie erleichtern auch das Testen erheblich, da man Actions isoliert erstellen und validieren kann. Ein weiterer Vorteil ist die bessere IDE-Unterstützung durch Autocompletion und Typchecking.

Die Struktur von Actions sollte flach und serialisierbar bleiben. Komplexe Objekte, Funktionen oder Klasseninstanzen gehören nicht in Actions. Wenn komplexe Datenstrukturen verarbeitet werden müssen, sollten diese vor der Action-Erstellung normalisiert werden. Dies gewährleistet, dass Actions problemlos über das Netzwerk übertragen oder in lokalen Storage gespeichert werden können.

24.4 Reducers - Pure Functions für vorhersagbare State-Updates

Reducers sind das Herzstück von Redux. Sie definieren, wie sich der State bei verschiedenen Actions verändert, und müssen dabei strikt als pure Functions implementiert werden. Ein Reducer nimmt zwei Parameter entgegen - den aktuellen State und eine Action - und gibt einen neuen State zurück.

function todosReducer(state: Todo[] = [], action: Action): Todo[] {
  switch (action.type) {
    case ActionTypes.ADD_TODO:
      return [
        ...state,
        {
          id: Date.now(),
          text: action.payload.text,
          completed: false,
          createdAt: new Date()
        }
      ];
    
    case ActionTypes.TOGGLE_TODO:
      return state.map(todo => 
        todo.id === action.payload.id 
          ? { ...todo, completed: !todo.completed }
          : todo
      );
    
    default:
      return state;
  }
}

Die Eigenschaft als pure Function bedeutet, dass Reducers niemals den eingehenden State direkt verändern dürfen. Stattdessen erstellen sie neue Objekte und Arrays mit den gewünschten Änderungen. Diese Immutabilität ist entscheidend für Redux’ Performance-Optimierungen und die Funktionsfähigkeit der Redux DevTools.

Ein häufiger Fehler besteht darin, Arrays oder Objekte im State direkt zu mutieren. Methoden wie push(), pop(), sort() oder direkte Eigenschaftszuweisungen sind in Reducers verboten. Stattdessen verwendet man immutable Updates mit Spread-Operator, map(), filter() oder speziellen Libraries wie Immer für komplexere Strukturen.

Der Default-Case im Reducer ist kritisch wichtig. Er sorgt dafür, dass bei unbekannten Actions der State unverändert zurückgegeben wird. Dies ermöglicht es Redux, Initialization Actions und andere interne Actions zu verarbeiten, ohne dass Reducers davon wissen müssen.

24.5 combineReducers - Modulare State-Struktur

In realen Anwendungen wird der State schnell komplex und unübersichtlich. combineReducers löst dieses Problem, indem es ermöglicht, den State-Baum in kleinere, spezifische Reducers aufzuteilen. Jeder Reducer ist nur für einen bestimmten Teil des States verantwortlich.

import { combineReducers } from 'redux';

const rootReducer = combineReducers({
  todos: todosReducer,
  filter: filterReducer,
  user: userReducer,
  ui: uiReducer
});

Diese Aufteilung bringt mehrere Vorteile. Der Code wird modularer und einfacher zu testen, da jeder Reducer isoliert entwickelt und getestet werden kann. Teams können parallel an verschiedenen State-Bereichen arbeiten, ohne sich gegenseitig zu behindern. Die Logik für verschiedene Domänen bleibt getrennt und gekapselt.

combineReducers folgt einer strengen Namenskonvention: Der Schlüssel im kombinierten Objekt wird zum Schlüssel im State-Baum. Wenn der todos-Reducer unter dem Schlüssel “todos” registriert wird, findet sich der todos-State später unter state.todos. Diese Vorhersagbarkeit erleichtert die Navigation durch komplexe State-Strukturen erheblich.

Ein wichtiger Aspekt ist, dass jeder Teil-Reducer nur seinen eigenen State-Slice sieht. Der todosReducer erhält nur das todos-Array, nicht den gesamten State-Baum. Dies fördert die Kapselung und verhindert ungewollte Abhängigkeiten zwischen verschiedenen State-Bereichen.

24.6 Selectors - Intelligenter State-Zugriff

Selectors sind Funktionen, die Daten aus dem Redux-State extrahieren und transformieren. Sie mögen zunächst wie ein zusätzlicher Abstraktionsoverhead erscheinen, erweisen sich aber als unverzichtbare Werkzeuge für wartbare Redux-Anwendungen. Selectors kapseln die State-Struktur und bieten eine stabile API für den Datenzugriff.

const selectors = {
  getTodos: (state: AppState): Todo[] => state.todos,
  getFilter: (state: AppState): FilterType => state.filter,
  
  getFilteredTodos: (state: AppState): Todo[] => {
    const todos = selectors.getTodos(state);
    const filter = selectors.getFilter(state);
    
    switch (filter) {
      case 'ACTIVE': return todos.filter(todo => !todo.completed);
      case 'COMPLETED': return todos.filter(todo => todo.completed);
      default: return todos;
    }
  }
};

Basis-Selectors greifen direkt auf State-Eigenschaften zu, während abgeleitete Selectors komplexere Berechnungen durchführen. Diese Unterscheidung ist wichtig für Performance-Optimierungen. Bibliotheken wie Reselect können abgeleitete Selectors memoizieren, sodass teure Berechnungen nur bei relevanten State-Änderungen erneut ausgeführt werden.

Die Verwendung von Selectors macht Refactoring einfacher. Wenn sich die State-Struktur ändert, müssen nur die betroffenen Selectors angepasst werden, nicht alle Komponenten, die auf diese Daten zugreifen. Dies reduziert die Kopplung zwischen State-Struktur und UI-Komponenten erheblich.

Selectors fördern auch die Wiederverwendung von Geschäftslogik. Filterungen, Sortierungen oder Berechnungen, die an mehreren Stellen benötigt werden, können in Selectors zentralisiert werden. Dies führt zu konsistenterem Verhalten und erleichtert die Wartung.

24.7 Redux DevTools - Debugging auf einem neuen Level

Die Redux DevTools sind eines der überzeugendsten Argumente für Redux. Sie bieten Einblicke in das Anwendungsverhalten, die mit anderen State-Management-Ansätzen schwer erreichbar sind. Jede Action wird mit Zeitstempel, Payload und resultierendem State-Change protokolliert.

Das Time-Travel-Debugging ermöglicht es, zu jedem beliebigen Punkt in der Action-History zurückzuspringen und den State zu diesem Zeitpunkt zu inspizieren. Dies ist besonders wertvoll beim Debugging komplexer Interaktionen oder beim Verstehen, wie ein bestimmter State-Zustand entstanden ist.

Die DevTools unterstützen auch Action-Replay und Hot-Reloading von Reducers. Bei Code-Änderungen bleibt die Action-History erhalten, und die Änderungen werden sofort auf den aktuellen State angewendet. Dies beschleunigt die Entwicklung erheblich und ermöglicht iteratives Experimentieren mit State-Logik.

Ein oft übersehener Aspekt der DevTools ist ihre Nützlichkeit für das Verstehen bestehender Anwendungen. Neue Teammitglieder können die Action-History betrachten und schnell verstehen, welche Benutzerinteraktionen zu welchen State-Änderungen führen. Dies macht Redux-Anwendungen selbstdokumentierend.

24.8 Integration mit React - Der Brückenschlag

Redux selbst ist eine reine JavaScript-Bibliothek ohne React-spezifische Funktionalität. Die Integration erfolgt durch react-redux, das Provider-Component und Hooks für den State-Zugriff bereitstellt. Der Provider macht den Redux-Store für alle Child-Komponenten verfügbar.

import { Provider } from 'react-redux';
import { createStore } from 'redux';

const store = createStore(rootReducer);

function App() {
  return (
    <Provider store={store}>
      <TodoApp />
    </Provider>
  );
}

Die useSelector und useDispatch Hooks ermöglichen es Komponenten, auf State zuzugreifen und Actions zu dispatchen. useSelector akzeptiert eine Selector-Funktion und gibt den ausgewählten State-Teil zurück. Die Komponente wird automatisch neu gerendert, wenn sich dieser State-Teil ändert.

import { useSelector, useDispatch } from 'react-redux';

function TodoList() {
  const todos = useSelector(selectors.getFilteredTodos);
  const dispatch = useDispatch();
  
  const handleAddTodo = (text: string) => {
    dispatch(addTodo(text));
  };
  
  // ...
}

Ein wichtiger Performance-Aspekt ist die Selector-Optimierung. useSelector vergleicht den Return-Wert mit strict equality (===). Wenn ein Selector immer neue Arrays oder Objekte zurückgibt, führt dies zu unnötigen Re-Renders. Hier helfen memoized Selectors oder das useCallback Hook für komplexere Selector-Logik.

24.9 Troubleshooting

Ein klassischer Fehler ist das direkte Mutieren von State in Reducers. JavaScript erlaubt dies syntaktisch, aber es bricht Redux’ Funktionsweise. Immutable Updates sind nicht nur Konvention, sondern technische Notwendigkeit für korrekte Re-Rendering-Logik und DevTools-Funktionalität.

Die übermäßige Granularität von Actions ist ein weiterer häufiger Fehler. Nicht jede kleine State-Änderung benötigt eine separate Action. Actions sollten meaningful business events repräsentieren, nicht jeden einzelnen Property-Update. Dies führt zu lesbarerem Code und aussagekräftigeren Action-Logs.

Die Normalisierung von State-Struktur wird oft vernachlässigt. Nested Objects und Arrays erschweren Updates und können zu Performance-Problemen führen. Eine flache, normalisierte State-Struktur mit IDs als Referenzen ist wartbarer und performanter.

Die Übernutzung von Redux ist ebenfalls problematisch. Nicht aller State gehört in Redux. Lokaler UI-State, der nur eine Komponente betrifft, sollte mit useState verwaltet werden. Redux eignet sich für shared State, der von mehreren Komponenten benötigt wird oder persistiert werden muss.

24.10 Performance-Überlegungen

Redux selbst ist sehr performant, aber falsche Verwendung kann zu Problemen führen. Häufige Re-Renders durch ineffiziente Selectors sind ein typisches Problem. Selectors, die bei jedem Aufruf neue Objects oder Arrays erstellen, lösen unnötige Re-Renders aus.

// Problematisch - erstellt bei jedem Aufruf neues Array
const getBadSelector = (state: AppState) => state.todos.filter(todo => todo.completed);

// Besser - mit Memoization
const getCompletedTodos = createSelector(
  [getTodos],
  (todos) => todos.filter(todo => todo.completed)
);

Die Größe des States beeinflusst die Performance. Sehr große State-Objekte verlangsamen Serialisierung für DevTools und können Memory-Probleme verursachen. Pagination, Virtualization oder das Entfernen nicht mehr benötigter Daten helfen bei der State-Größe-Kontrolle.

Die Anzahl der Store-Subscriber kann ebenfalls zum Bottleneck werden. Jede mit useSelector verbundene Komponente ist ein Subscriber. Bei sehr vielen Komponenten kann das Benachrichtigen aller Subscriber nach State-Changes merklich Zeit kosten.