post

State-Management in React: Redux Toolkit, leichte Stores, MobX

Wenn ein Zustand wirklich app-weit ist, stellt sich die Werkzeugfrage – Redux Toolkit, ein leichter Store wie Zustand oder Jotai, oder das reaktive Modell von MobX. Warum diese Wahl aber erst nach der Gegenprobe kommt und meistens kleiner ausfällt als gedacht.

State-Management in React: Redux Toolkit, leichte Stores, MobX

Im React-Überblick habe ich die Besitzfrage in den Mittelpunkt gestellt – wo gehört ein Zustand hin? – und dabei einen Satz gesetzt, der in Schulungen regelmäßig für Stirnrunzeln sorgt: Ein globaler Store ist die Ausnahme, die man begründen muss, nicht der Normalfall. Dort blieb offen, was man denn nimmt, wenn ein Zustand diese Begründung tatsächlich besteht – wenn er wirklich app-weit ist. Ich hatte einen eigenen Vergleich versprochen. Das ist er.

Bevor wir über Werkzeuge reden, muss ich aber auf die Bremse treten. Die Frage „Redux, Zustand oder MobX?“ wird in Projekten meistens viel zu früh gestellt – zu einem Zeitpunkt, an dem noch gar nicht feststeht, ob überhaupt ein Store gebraucht wird. Und in neun von zehn Fällen, die ich in Schulungen sehe, stellt sich beim genauen Hinsehen heraus: Der vermeintlich globale Zustand ist keiner. Deshalb kommt zuerst die Gegenprobe, und erst danach der eigentliche Vergleich.

Die Gegenprobe zuerst

Wenn ein Team sagt „wir brauchen einen globalen Store“, lasse ich mir zeigen, was da eigentlich hineinsoll. Dann streichen wir gemeinsam. Der überraschend große Teil davon ist in Wahrheit URL-Zustand: Welcher Filter aktiv ist, welche Seite man sieht, welcher Datensatz gerade ausgewählt ist, welcher Tab offen ist – all das gehört in die Adresse. Dort ist es teilbar, mit Lesezeichen speicherbar, überlebt einen Reload und einen geteilten Link. Ein Store, der diese Werte hält, baut denselben Zustand ein zweites Mal, nur schlechter, weil er nach jedem Neuladen leer ist.

Der nächste große Block ist noch tückischer, weil er wie Zustand aussieht und sich wie Zustand anfühlt: geladene Daten. Die Liste der Rechnungen, das Nutzerprofil, das Suchergebnis. Das ist aber kein Zustand, den man besitzt, sondern eine Kopie von etwas, das dem Backend gehört – ein Cache. Warum das eine eigene Kategorie mit eigenen Regeln ist und warum ein Store dafür das falsche Zuhause ist, habe ich im Beitrag Client-Zustand vs. Server-Cache ausführlich behandelt. Kurzfassung: Serverdaten gehören in eine Cache-Bibliothek wie TanStack Query oder SWR, nicht in einen globalen Store, wo sie veralten und mit jeder Mutation von Hand synchron gehalten werden müssten.

Nimmt man diese beiden Blöcke heraus – URL-Zustand nach oben in die Adresse, Serverdaten in den Cache –, bleibt erschreckend wenig übrig. Der echte, app-weite Client-Zustand ist ein kleiner Kern: die angemeldete Person mit ihren Rechten, das Theme, die Sprache, vielleicht ein Warenkorb, der über mehrere Seiten getragen werden muss. Werte also, die viele weit auseinanderliegende Stellen der Anwendung brauchen, die nicht in die URL gehören und die keinem Server gehören. Nur für diesen Kern lohnt sich überhaupt ein Store – und weil der Kern klein ist, darf auch das Werkzeug klein sein.

flowchart TB
  Start["Ein Wert, der wie<br/>globaler Zustand aussieht"]
  Start --> Global{"Wirklich app-weit,<br/>oder nur lokal gebraucht?"}
  Global -->|nur eine Ecke| Lokal["useState / useReducer<br/>in der Komponente"]
  Global -->|app-weit| URL{"Gehört er in die URL?<br/>Filter, Seite, Auswahl"}
  URL -->|ja| Adresse["Router / URL<br/>teilbar, überlebt Reload"]
  URL -->|nein| Server{"Gehört die Wahrheit<br/>dem Backend?"}
  Server -->|ja| Cache["Server-Cache<br/>TanStack Query / SWR"]
  Server -->|nein| Store["Echter Kern: erst<br/>jetzt Store-Wahl"]

Die Reihenfolge ist der ganze Punkt. Wer mit der Werkzeugfrage anfängt, füllt den Store mit Dingen, die dort nichts zu suchen haben, und wundert sich später über Synchronisationsbugs. Wer mit der Gegenprobe anfängt, kommt oft mit useState und der URL durch und braucht gar keinen Store. Erst wenn nach dem Streichen ein Kern übrig bleibt, lohnt der Rest dieses Beitrags.

Drei Familien für den echten Kern

Angenommen, dieser Kern ist da. Dann gibt es grob drei Denkschulen, wie man app-weiten Zustand hält. Ich zeige jede an einem winzigen Beispiel und sage dazu ehrlich, was sie kostet – denn keine ist umsonst.

Redux Toolkit: das klassische Modell, entschlackt

Redux hat ein klares Modell: Es gibt genau einen Store, einen zentralen Zustandsbaum. Man ändert ihn nie direkt, sondern schickt Actions – beschreibende Nachrichten wie „increment“ –, und reine Funktionen namens Reducer berechnen aus altem Zustand plus Action den neuen. Dieses Modell hat Redux jahrelang den Ruf eingebracht, umständlich zu sein: viel Zeremonie, Action-Typen als Konstanten, Boilerplate für jeden Handgriff.

Redux Toolkit – kurz RTK, heute die offiziell empfohlene Art, Redux zu schreiben – nimmt genau diese Zeremonie ab. Ein createSlice bündelt Zustand, Actions und Reducer in einem Stück. Bemerkenswert ist der Reducer-Code: Er sieht aus, als würde man den Zustand direkt mutieren, bleibt aber unter der Haube immutabel, weil RTK die Bibliothek Immer benutzt, die aus den scheinbaren Mutationen eine saubere neue Kopie macht.

import { configureStore, createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    addItem(state, action) {
      // looks mutable, stays immutable via Immer
      state.items.push(action.payload);
    },
    clear(state) {
      state.items = [];
    },
  },
});

export const { addItem, clear } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });

Das configureStore setzt den Store mit sinnvollen Voreinstellungen auf – die DevTools sind schon verdrahtet, gängige Middleware ist dabei. In TypeScript legt man sich zusätzlich einmal typsichere Hooks an (useAppSelector, useAppDispatch), sodass Zustand und Actions durchgehend typisiert sind.

Die Stärke von RTK liegt in der Nachvollziehbarkeit. Jede Zustandsänderung ist eine benannte Action, die durch einen Reducer läuft – ein protokollierbarer, reproduzierbarer Übergang. Die DevTools zeigen jede Action in der Reihenfolge, erlauben Time-Travel durch die Zustandshistorie und machen aus „warum steht da plötzlich dieser Wert?“ eine Frage, die man beantworten kann, statt sie zu erraten. In großen Teams und bei komplexen Abläufen ist das Gold wert: Der Zustandsfluss ist eine Konvention, die alle teilen, und keine Frage des persönlichen Stils. Der Preis ist ebenso ehrlich zu nennen: mehr Struktur, mehr Dateien, ein zentraler Baum, durch den alles läuft. Für den kleinen Kern einer mittelgroßen Anwendung ist das oft mehr Apparat, als der Kern verdient.

Leichte Stores: Zustand und Jotai

Die zweite Familie setzt genau dort an: minimale API, kein Provider-Zwang, so wenig Code wie möglich. Zwei Vertreter aus demselben Umfeld, die aber unterschiedlich denken.

Zustand (ja, die Bibliothek heißt so – ein deutsches Wort als Name) gibt einem eine Funktion create, die einen Hook zurückliefert. Aus diesem Hook liest man per Selektor genau die Scheibe des Zustands, die eine Komponente braucht. Der Selektor ist der Clou: Eine Komponente, die nur count liest, rendert nicht neu, wenn sich ein anderes Feld im Store ändert. Die Re-Renders bleiben granular, ohne dass man Context-Provider verschachteln muss.

import { create } from "zustand";

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clear: () => set({ items: [] }),
}));

// in a component: read only what you need
const count = useCartStore((state) => state.items.length);

Jotai geht von der anderen Seite heran. Statt eines Stores, aus dem man Scheiben liest, gibt es viele kleine, atomare Zustandseinheiten – Atoms –, die man frei kombiniert. Ein Atom hält einen Wert, abgeleitete Atoms rechnen aus anderen Atoms. Eine Komponente abonniert genau die Atoms, die sie liest, und mehr nicht.

import { atom, useAtom } from "jotai";

const itemsAtom = atom([]);
const countAtom = atom((get) => get(itemsAtom).length);

// in a component
const [count] = useAtom(countAtom);

Die Stärke beider ist offensichtlich: sehr wenig Code, feingranulare Updates, ein kleines Bundle. Man ist in Minuten produktiv, es gibt kaum Konzepte zu lernen. Der Preis ist die Kehrseite derselben Medaille – weniger Konvention, weniger vorgegebene Struktur. Redux zwingt einen in ein Muster; ein leichter Store lässt einem alle Freiheiten, auch die, sich ein Chaos zu bauen. Die Disziplin, den Store klein und geordnet zu halten und nicht doch wieder Serverdaten hineinzukippen, liegt beim Team. Für den kleinen Kern, um den es hier geht, ist das oft genau die richtige Größe.

MobX: der reaktive Ansatz

Die dritte Familie denkt grundsätzlich anders. Redux und die leichten Stores sind im Kern immutabel: Zustand ändern heißt, einen neuen Zustand herstellen. MobX dreht das um. Man beschreibt beobachtbaren Zustand, mutiert ihn ganz direkt, und die Bibliothek verfolgt selbständig, wer was liest, und aktualisiert genau die Stellen, die es angeht.

Man macht ein Objekt mit makeAutoObservable beobachtbar. Abgeleitete Werte deklariert man als computed – hier als Getter –, und sie aktualisieren sich von allein, sobald sich ihre Grundlage ändert. Komponenten, die man in observer einwickelt, reagieren gezielt auf genau die beobachteten Werte, die sie im Rendern gelesen haben.

import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";

class CartStore {
  items = [];
  constructor() {
    makeAutoObservable(this);
  }
  addItem(item) {
    this.items.push(item); // direct mutation is fine here
  }
  get count() {
    return this.items.length; // derived, recomputes automatically
  }
}

const cart = new CartStore();
const CartBadge = observer(() => <span>{cart.count}</span>);

MobX glänzt, wenn viel Zustand voneinander abgeleitet ist. Wo man in Redux Selektoren schreiben und memoisieren müsste, ergibt sich der abgeleitete Wert bei MobX von selbst und wird nur dann neu berechnet, wenn seine Eingaben sich ändern. Es steht auffällig wenig Verdrahtungscode herum. Der Preis ist die Implizitheit: Genau weil man nichts explizit dispatcht, ist die Frage „wo kommt diese Änderung her?“ schwerer zu beantworten – die Aktualisierung passiert im Hintergrund, ausgelöst durch eine Mutation, die irgendwo im Code steht. Und es ist ein anderes mentales Modell als der Rest des React-Ökosystems: gezielte Mutation statt Immutabilität. Wer aus der Redux-Welt kommt, muss umdenken; wer objektorientiert denkt, fühlt sich sofort zu Hause.

Woran man die Wahl festmacht

Es gibt keine Rangliste, die für alle stimmt – die Wahl hängt an ein paar Eigenschaften des Projekts, und die muss man ehrlich benennen. Die Team-Größe ist die erste: Je mehr Leute an demselben Zustand arbeiten, desto mehr zahlt sich eine erzwungene Konvention aus, weil sie Diskussionen erspart und den Code über Personen hinweg gleich aussehen lässt. Die zweite ist der Bedarf an Nachvollziehbarkeit – braucht man protokollierbare, auditierbare Übergänge, will man im Fehlerfall durch die Zustandshistorie zurückspulen? Dann führt an den Redux-DevTools und ihrem Time-Travel wenig vorbei. Die dritte ist die Menge an abgeleitetem Zustand: Rechnet man ständig Werte aus anderen Werten aus, spielt das reaktive Modell von MobX seine Stärke aus; ist der Zustand dagegen flach und wird meist nur gelesen und gesetzt, ist dieser Vorteil wertlos. Dazu kommen das Bundle-Budget – jedes Kilobyte zählt bei den leichten Stores für sie und bei Redux gegen es – und schlicht die Vertrautheit des Teams, die man nicht unterschätzen sollte: Ein Werkzeug, das alle kennen, schlägt oft das theoretisch bessere, das erst gelernt werden muss.

Daraus lassen sich ein paar Faustregeln ableiten, ohne dass sie Gesetze wären. Ist der Kern wirklich klein – eine angemeldete Person, ein Theme, ein Warenkorb –, reichen Context mit useReducer oder ein leichter Store wie Zustand, und mehr wäre überzogen. Steht ein großes Team vor komplexen Abläufen mit dem Wunsch nach auditierbaren Übergängen, ist Redux Toolkit die naheliegende Wahl, weil es genau diese Nachvollziehbarkeit zur Konvention macht. Lebt die Anwendung von viel abgeleitetem Zustand, der sich kaskadierend aktualisieren soll, spielt MobX seine Stärke aus. Und wenn zwei Optionen gleich gut passen, gibt die Vertrautheit des Teams den Ausschlag.

Die Wahl ist nachrangig

Was ich in Schulungen am Ende betone: Kein Werkzeug ist „das beste“. Diese Vergleiche werden mit einer Ernsthaftigkeit geführt, die der Sache nicht angemessen ist – als hinge der Projekterfolg an Redux gegen MobX. Tut er nicht. Die Wahl des Stores ist nachrangig gegenüber der Besitzfrage, die davor kommt. Wer die URL für das nutzt, was in die URL gehört, und Serverdaten als Cache behandelt statt als Zustand, hält den echten Client-Zustand so klein, dass fast jede der drei Familien reicht – und die Entscheidung dazwischen zur Geschmacksfrage schrumpft.

Der Fehler, den die Werkzeugdebatte verdeckt, ist der teurere: einen großen globalen Store zu bauen und ihn mit URL-Zustand und Serverdaten zu füllen, die dort nicht hingehören. Kein Redux Toolkit und kein MobX rettet einen vor den Bugs, die daraus entstehen. Umgekehrt kommt man mit sauber gezogenen Grenzen und dem simpelsten der leichten Stores erstaunlich weit. Deshalb steht die Gegenprobe am Anfang dieses Beitrags und nicht am Ende: Sie entscheidet mehr als die Tabelle mit den drei Spalten danach.

Weiterführende Quellen

Kommentare

Kommentar schreiben