Development

Kolumne: Olis bunte Welt der IT

Wenn’s etwas weniger sein darf: React und Redux kombiniert
Keine Kommentare

Redux, Reduktion, reduzieren, kleiner, weniger … aber was? Arbeit? Das wäre gut. Funktionalität? Nicht so gut. Weniger ist oft mehr, das ist klar! Mal sehen, was Redux so kann…

Der Name Redux hat mit „weniger“ und „kleiner“ nur bedingt zu tun, denn er lehnt sich an die Funktion reduce an, die im Umfeld funktionaler Programmierung schon seit Jahrzehnten der Aggregierung von Daten dient. Dabei geht es durchaus um Reduktion, aber im Sinne des französischen „Jus“, das Sie womöglich aus der Küche kennen: Flüssigkeiten werden konzentriert und im Volumen reduziert, während die leckeren Inhaltsstoffe erhalten bleiben. Redux arbeitet mit Reducern, also Funktionen, die der Reduktion dienen. Ach so. Na dann.

Im Kern geht es Zustandsverwaltung. Wir wissen, dass diese nicht einfach ist. Irgendwann war die mal einfach, als man sich für globale Variablen bei der Programmierung mit BASIC oder gar C noch nicht so sehr schämte wie heute. Aber die Erinnerung täuscht selbst hier gern: Letztlich war hauptsächlich deshalb alles einfacher, weil man im eingeschränkten Speicher der damaligen Computer sowieso nicht Programme derselben Komplexität bauen konnte, wie sie heute überall geschrieben werden. Sobald in umfangreichen Anwendungen State, also Zustandsinformation, verwaltet und zwischen verschiedenen Bestandteilen und Modulen koordiniert werden muss, wird die Sache ziemlich schwierig.

Zustand macht alles schwieriger

Seit vielen Jahren schreibe und rede ich selbst gern über die Vorzüge der funktionalen Idee beim Umgang mit State. Vielleicht haben Sie einmal eine meiner Präsentationen gesehen, in denen es um unveränderbare Daten ging. Solche Konzepte waren in C# umsetzbar, aber verursachten immer etwas zusätzlichen Aufwand beim Umgang mit Daten, weil in der statisch typisierten Umgebung von .NET nicht einfach ein vollwertiger Ersatz für Standardtypen erzeugt werden kann, der die Veränderung von Daten unmöglich macht.

In JavaScript ist der Umgang mit unveränderbaren Daten heute recht einfach. Ich benutze selbst gern das Paket seamless-immutable. Damit kann ich etwa ein unveränderbares Objekt so erzeugen:

const person = Immutable({ name: 'Oli', age: 23 });

Das geht auch mit Arrays und beliebig komplexen Daten, und seamless-immutable sorgt nun dafür, dass Properties und Array-Inhalte sich später nicht in-place ändern können.

Bei der praktischen Anwendung solcher Datentypen stellen sich nun einige Fragen. Eine der wichtigsten ist, wie mit der Tatsache umgegangen werden soll, dass sich in der wirklichen Welt manchmal Informationen ändern. Die Antwort ist im Prinzip einfach: Wenn es Änderungen gibt, werden neue Objekte erstellt, statt die vorhandenen zu ändern. Auch das kann seamless-immutable (Listing 1).

const people = Immutable([{ name: 'Oli', age: 23}, { name: 'Anna', age: 32}]);
// people ist nun:
// [ { name: 'Oli', age: 23 }, { name: 'Anna', age: 32 } ]

const newPeople = people.setIn([0, 'age'], 24);
// newPeople ist nun:
// [ { name: 'Oli', age: 24 }, { name: 'Anna', age: 32 } ]

Wunderbar. Was bleibt? Es bleibt ein großes Problem. Wenn ich davon ausgehe, dass in einer durchschnittlichen Anwendung viele Objekte und Listen von Objekten gehalten werden, dann müssen alle Veränderungen, die irgendwann auftreten, koordiniert werden. Das ist wichtig, da offensichtlich jede Veränderung an einem Objekt, das selbst Bestandteil eines anderen Objekts (oder einer Liste) ist, zur Veränderung dieses übergeordneten Objekts führt.

Änderungen sind ansteckend

Dies ist das Problem, das Redux löst: die Verwaltung eines zentralen Zustandscontainers und die Verarbeitung von Änderungen an einzelnen Blöcken von State.

Die Beispiele, die ich im Folgenden für Redux zeigen werde, basieren auf der Verwendung mit einfachem JavaScript und React. Natürlich können Sie Redux auch mit anderen Plattformen einsetzen – ich habe selbst erfolgreich eine Konsolenanwendung damit gebaut, ganz ohne grafische Oberfläche. Wenn Sie gern, einfach so, viel mehr Code schreiben als nötig, können Sie auch Angular oder gar TypeScript verwenden. Die Grundlagen von Redux ändern sich durch diese Entscheidungen natürlich nicht.

Für Redux beginnt alles mit der Erzeugung von Actions. Das sind kleine Objekte, die Aktionen repräsentieren, mit all den Details, die für eine Aktion jeweils relevant sind. Meist beginnt man mit der Definition der Aktionstypen als Strings:

const ADD_TODO = 'ADD_TODO';
const CHANGE_DONE = 'CHANGE_DONE';

Aktionstypen müssen in der Anwendung eindeutig sein, eventuell sollten Sie also etwas komplexere Bezeichner verwenden. Im nächsten Schritt definieren Sie sich des Komforts halber ein paar Hilfsfunktionen, die zur Erzeugung der Action-Objekte dienen und sicherstellen, dass diese immer dieselbe Struktur haben.

const addTodo = text => ({ type: ADD_TODO, payload: { text } });
const changeDone = (id, done) => ({ type: CHANGE_DONE, payload: { id, done } });

An dieser Stelle können TypeScript-Anhänger und andere Fans der objektorientierten Programmierung natürlich auch gern Klassen mit Konstruktoren verwenden. Die gezeigte Struktur ist übrigens eine Best Practice, kann aber beliebig an eigene Vorlieben angepasst werden.

An dieser Stelle kommt nun der Reducer ins Spiel. In der Anwendung werden letztlich Aktionen verschickt – der englische Begriff ist „dispatch“ – also an Redux übergeben. Zum Beispiel könnte ein Klick des Benutzers auf einen Button die Aktion ADD_TODO auslösen, also die Erzeugung eines neuen Elements. Wenn Redux eine Aktion empfängt, von der UI oder aus einer anderen Quelle, ruft es alle bekannten Reducer auf, jeweils unter Übergabe des vorherigen bzw. aktuellen State und der Action selbst. Ein Reducer für das Beispiel könnte etwa so aussehen wie in Listing 2.

const todoReducer = (state = Immutable([]), action) => {
  switch (action.type) {
    case ADD_TODO:
      return state.concat({ done: false, text: action.payload.text });
    case CHANGE_DONE:
      return state.setIn([action.payload.id, 'done'], action.payload.done);
    default:
      return state;
  }
};

Sie können der Funktionsdeklaration entnehmen, dass der State ein (unveränderbares) Array sein soll – dazu gleich noch mehr. Der Reducer hat die Aufgabe, anhand der Action und des übergebenen State einen neuen State zu berechnen. Wie er das macht, ist allein dem Programmierer überlassen. Im Beispiel wird die Funktion setIn verwendet, wie schon vorher demonstriert, bzw. concat für den Vorgang, ein neues Element an das Array anzuhängen.

Reducer erzeugen immer neue „State“-Objekte

Wichtig ist, dass analog zur erwähnten Logik der unveränderbaren Daten jeweils ein neues Objekt als Resultat berechnet wird. Es wird nichts am vorhandenen State geändert! Natürlich könnten Sie theoretisch denselben Code auch ohne Immutable schreiben, und dann ist eine Änderung des State technisch möglich. Damit würden Sie allerdings nur erreichen, dass später aufgerufene Reducer mit einer falschen Ausgangssituation konfrontiert würden – das gilt es zu vermeiden!

Behalten Sie ebenfalls im Kopf, dass für jede Aktion, die von Redux verarbeitet wird, alle Reducer aufgerufen werden. Wenn ein Reducer zu einer bestimmten Aktion nichts beizutragen hat, gibt er einfach den vorhandenen State unverändert zurück. Somit haben große Zahlen von Reducern im System nur wenig Overhead.

Gewöhnlich schreibe ich meine Reducer noch ein wenig anders als im Beispiel:

const createTodoReducer = initialState => (state = initialState, action) => {
  ...
}

Ich erzeuge anstelle des Reducers selbst zunächst eine Funktion, der ein Ausgangszustand übergeben werden kann, bevor sie den eigentlichen Reducer zurückliefert. Das finde ich praktisch, da ich somit die Ausgangszustände aller Reducer im System an zentraler Stelle definieren kann, statt sie in den Implementationen der Reducer zu verstecken.

Zur Initialisierung von Redux in einer React-Anwendung sind nun noch ein paar Schritte erforderlich, die direkt nach dem Start ausgeführt werden. Zunächst erzeugen Sie Ihre Reducer:

const todoReducer = createTodoReducer(
  Immutable([
    { done: true, text: "Read Oli's Redux article" },
    { done: false, text: 'Play with Redux yourself' }
  ])
);

Wenn Sie tatsächlich nur einen Reducer haben, ist der folgende Schritt eigentlich nicht notwendig. Ich empfehle ihn aber trotzdem, denn in einer echten Anwendung haben Sie natürlich schnell mehr als nur einen Reducer. Der Schritt besteht darin, aus den verschiedenen Reducern im System einen einzigen zu machen, bzw. die States aller Reducer in einem Objekt zu kombinieren.

const reducers = combineReducers({
  todos: todoReducer
});

Die Funktion combineReducers stammt aus dem Paket „redux“. In diesem Aufruf wird festgelegt, dass das Unterobjekt todos der Teil der Zustandsdaten ist, um den sich der todoReducer kümmert. Redux sorgt selbst dafür, dass beim Aufruf dieses Reducers nur der angegebene Teil als vorheriger State übergeben wird. Somit muss nicht jeder einzelne Reducer wissen, was sonst noch für Teile in der App existieren.

Soweit es Redux betrifft, gibt es nur noch einen weiteren Schritt: die Erzeugung des Store-Objekts. Oft beginnen Beschreibungen von Redux mit diesem Objekt, denn es ist zentral im System. Der Store hält die Zustandsdaten und ist für die Verarbeitung von Aktionen zuständig. Im Beispiel geht das ganz einfach mit der Funktion createStore, ebenfalls aus dem Paket „redux“:

const store = createStore(reducers);

Oft sieht man eine etwas kompliziertere Form des Aufrufs, etwa so:

const store = createStore(
  reducers,
  window.__REDUX_DEVTOOLS_EXTENSION__ &&
    window.__REDUX_DEVTOOLS_EXTENSION__()
);

Diese Variante sorgt dafür, dass Redux mit den Redux Dev Tools kommuniziert, die man beispielsweise in Chrome installieren kann. Das ist sehr zu empfehlen, denn diese Tools sind sehr mächtig und können in der Aktionssequenz der laufenden Anwendung beliebig navigieren, etwa vergangene Zustände wiederherstellen und einzelne Aktionen im Detail anzeigen.

Nun fehlen noch ein paar kleine Schritte, die zur Anbindung von React notwendig sind. Aus dem Paket „react-redux“ holen Sie sich den Provider, der so eingesetzt wird:

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Sie sehen, dass der Provider eine Art Wrapper für das Rendering der gesamten Anwendung darstellt. Dadurch können sich einzelne Komponenten an beliebiger Stelle der Hierarchie mit dem Store verbinden. Die Komponente in Listing 3 könnte etwa verwendet werden, um die Elemente der Todo-Liste in einer verkürzten Form auszugeben.

const TodoList = ({ todos, newItem }) => (
  <div>
    <ul>
      {todos.map((item, index) => (
        <li key={index}>{item.text}</li>
      ))}
    </ul>
    <button onClick={newItem}>New Item</button>
  </div>
);

Die Komponente TodoList erhält zwei Parameter aus ihren Props: die Liste von Todo-Elementen und einen Event Handler namens newItem. Beide Parameter werden bei der Ausgabe verwendet. Aus der Liste der Elemente wird eine Sequenz von <li>-Tags generiert, und der Event Handler wird an einen Button angebunden. Aber woher stammen diese Parameter? Die Antwort ergibt sich aus dem Code in Listing 4, der auf die Deklaration der Komponente folgt.

const stateToProps = state => ({ todos: state.todos });
const dispatchToProps = dispatch => ({
  newItem: () => dispatch(addTodo('New Todo'))
});
const ConnectedTodoList = connect(
  stateToProps,
  dispatchToProps
)(React.memo(TodoList));

Zur Verbindung der Komponente mit Redux werden zwei Funktionen benötigt. stateToProps bekommt das kombinierte State-Objekt übergeben und extrahiert den Teil, für den sich die Komponente interessiert. dispatchToProps empfängt eine Referenz auf die dispatch-Funktion des Store, und erzeugt den Event Handler newItem, in dem dispatch mit einer Aktion aufgerufen wird. Sie erinnern sich an addTodo? Diese Hilfsfunktion, mit der eine Instanz der Aktion erzeugt wird, habe ich oben beschrieben.

Fazit

Damit ist das Ende erreicht! Wir haben jetzt eine Anwendung, in der die React Views als Funktion des Zustands berechnet werden, so ganz im mathematischen Sinne. Redux sorgt dafür, dass dieser Zustand zentral gehalten werden kann, modifiziert einzig auf dem Weg über Aktionen und Reducer. Alle Daten im System sind unveränderbar, was funktional sauber ist und außerdem sehr effizient für Updates der UI, denn React braucht lediglich Objektreferenzen zu vergleichen, um festzustellen, ob sich etwas am Zustand geändert hat.

Wenn Sie Interesse an Redux gefunden haben, empfehle ich Ihnen, diesem Link zu folgen. Dort können Sie direkt online mit einer Demoanwendung experimentieren, in der Sie die Elemente aus diesem Artikel wiederfinden.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -