post

Tyrian Rx React: UI als Datenfluss

Ein Nachbau der Elm-Architektur mit RxJS und React – Actions als Strom, scan() faltet das Model, Nebenwirkungen bleiben am Rand – und was dieser Proof of Concept über gut alternde UI-Architektur verrät.

≈ 9 Min. Lesezeit

Diesen Beitrag anhören (13 Min.)

MP3 herunterladen

Wenn in einer Oberfläche jede Komponente ihren eigenen Zustand mutiert, verliert das System als Erstes die Zeitachse. Ein Klick hier, ein Callback dort, ein setState irgendwo dazwischen – und drei Wochen später kann niemand mehr sagen, wann der Zustand gekippt ist, der den Fehler ausgelöst hat. Der Debugger zeigt das Ergebnis, aber nicht die Geschichte. Man sieht: count ist 7, obwohl er 3 sein müsste. Man sieht nicht: welche Folge von Ereignissen aus der 3 eine 7 gemacht hat, in welcher Reihenfolge zwei Antworten vom Server eintrafen, ob der Benutzer schneller geklickt hat als das Netzwerk geantwortet.

Genau gegen diesen Verlust der Geschichte ist die Elm-Architektur gebaut. Und weil man eine Architektur erst versteht, wenn man sie einmal selbst nachgebaut hat, habe ich das in den letzten Wochen gemacht: die Elm-Architektur mit RxJS und React – als Proof of Concept, nicht als Framework. Das Repository liegt unter github.com/MikeBild/reactish-elmish, und schon das README warnt in der zweiten Zeile: Don't use in production! Diese Warnung ist ernst gemeint, und ich komme am Ende darauf zurück. Der Wert des Experiments liegt woanders.

Die Elm-Architektur, auf das Skelett reduziert

Elm – seit Version 0.17 vom Mai dieses Jahres ohne Signals, dafür mit Subscriptions – zwingt jede Anwendung in dieselbe Form:

  • ein Model, das den gesamten Zustand der Anwendung als einen Wert hält
  • eine update-Funktion, die aus altem Model und einer Message ein neues Model errechnet
  • eine view-Funktion, die aus dem Model die Darstellung erzeugt
  • Subscriptions für alles, was von außen hereinkommt: Timer, WebSockets, Browser-Ereignisse

Kein Teil davon ist optional, kein Teil kennt einen Seitenausgang. Der Compiler lässt schlicht nichts anderes zu. Zustand entsteht nirgendwo nebenbei; er entsteht ausschließlich dadurch, dass update eine Message verarbeitet. Damit ist die Geschichte des Zustands identisch mit der Liste der Messages – und die Frage „wann ist das passiert?" hat wieder eine Antwort: bei Message Nummer 34, zwischen 33 und 35.

Das Bemerkenswerte an dieser Form ist, wie wenig sie mit der Sprache Elm zu tun hat. Model, Update, View – das ist eine Faltung über eine Ereignisfolge plus eine Projektion. Beides sind Konzepte, die es in jeder Sprache gibt. Die Frage, die mich nicht losließ: Wie viel von der Klarheit bleibt übrig, wenn man den Compiler weglässt und die Form freiwillig in JavaScript nachbaut?

Der Nachbau: Actions als Strom

Der Kern des Nachbaus passt in wenige Zeilen. Jede Benutzerinteraktion wird zu einem Wert auf einem Strom, und eine einzige update-Funktion faltet diesen Strom zum Model. Im Repository steckt das hinter einer kleinen API aus init, update, view und subscriptions – hier die Rohform ohne Verpackung:

import Rx from 'rx'

// every user interaction becomes a value on this stream
const actions$ = new Rx.Subject()

function update (model, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: model.count + 1 }
    case 'DECREMENT':
      return { count: model.count - 1 }
    case 'TICK':
      return { count: model.count + action.step }
    default:
      return model
  }
}

// fold the action history into the current model
const model$ = actions$
  .startWith({ type: 'INIT' })
  .scan(update, { count: 0 })

Eine Randnotiz zur Wahl der Bibliothek: Der Prototyp benutzt das klassische rx-Paket, also Version 4 mit onNext und dispose. RxJS 5 steht als Release Candidate bereit und wird die Namen an die ES-Observable-Vorschläge angleichen (next, unsubscribe), aber für ein Experiment wollte ich die stabile Version, deren Operator-Dokumentation vollständig ist. Am Konzept ändert das nichts – Methodenketten auf Observables funktionieren in beiden Welten gleich.

Der entscheidende Operator ist scan. Wer reduce über Arrays kennt, kennt scan fast: Es ist dieselbe Faltung, nur dass sie nicht auf ein fertiges Array wartet, sondern bei jedem neuen Element den Zwischenstand herausgibt. reduce faltet eine abgeschlossene Liste zu einem Endwert. scan faltet eine Liste, die nie endet – und genau das ist eine Benutzeroberfläche: eine Ereignisfolge ohne Schlusspunkt. Das Model zu einem Zeitpunkt ist nichts anderes als die Faltung aller Actions bis zu diesem Zeitpunkt. Diese eine Einsicht ist der halbe Artikel: Zustand ist der linke Fold über die Ereignisgeschichte.

React rendert nur noch

React kommt in diesem Aufbau eine bewusst langweilige Rolle zu: Es projiziert das aktuelle Model in den DOM und meldet Interaktionen zurück auf den Action-Strom. Kein Fetch in componentDidMount, keine Zustandslogik in Handlern – die Komponente folgt dem model$-Strom und sonst nichts:

import React from 'react'

class CounterView extends React.Component {
  componentDidMount () {
    // the view follows the model stream, nothing else
    this.subscription = model$.subscribe(model => this.setState({ model }))
  }

  componentWillUnmount () {
    this.subscription.dispose()
  }

  render () {
    if (!this.state) return null
    const { count } = this.state.model
    return (
      <div>
        <span>{count}</span>
        <button onClick={() => actions$.onNext({ type: 'INCREMENT' })}>+</button>
        <button onClick={() => actions$.onNext({ type: 'DECREMENT' })}>-</button>
      </div>
    )
  }
}

Dass hier trotzdem setState auftaucht, ist kein Stilbruch, sondern der Übergabepunkt: Irgendwo muss der Strom in Reacts Render-Zyklus münden, und setState ist in React 15 nun einmal die Tür. Wichtig ist, dass es die einzige Stelle ist, an der setState vorkommt – als Brücke, nicht als Zustandsverwaltung. Im Repository ist diese Brücke in zwei Varianten verpackt, einmal als Higher-Order-Component withElmish für die Komposition mit zustandslosen Funktionskomponenten, einmal als Basisklasse ReactComponent, von der man erbt und init, update, view überschreibt.

An dieser Stelle weicht der Nachbau übrigens bewusst von Elm und von Redux ab: Er hält den Zustand pro Komponente statt in einem zentralen Store. Jede „smarte" Komponente bekommt ihre eigene kleine Elm-Schleife, „dumme" Komponenten bleiben zustandslose Funktionen. Ob das eine gute Idee ist, hängt vom Zuschnitt ab – für die Beispielsammlung im Repository (Counter, Formular-Validierung, optimistische UI-Updates, ein Bestellprozess als lang laufender Saga-Strom) war es angenehm, weil jedes Beispiel für sich lebt. Für eine große Anwendung würde ich die Frage neu stellen.

Nebenwirkungen als Ströme am Rand

Der interessanteste Teil ist der, den Counter-Beispiele gern unterschlagen: Was passiert mit allem, das nicht aus einem Klick entsteht? Timer, HTTP-Antworten, Server-Push. In Elm sind das Subscriptions und Tasks. Im Nachbau sind es – natürlich – Observables. Die subscriptions-Funktion bekommt ein Kommando aus init oder update und antwortet mit einem Strom, dessen Werte wieder als Messages in update landen:

const stop$ = actions$.filter(action => action.type === 'INTERVAL_STOP')

function subscriptions (cmd) {
  switch (cmd) {
    case 'INTERVAL_START':
      return Rx.Observable
        .interval(1000)
        .map(() => ({ type: 'TICK', step: 1 }))
        .takeUntil(stop$)
  }
}

Die Laufzeitschicht abonniert diesen Strom selbst; die Komponente ruft nirgendwo subscribe auf. Damit bleiben Nebenwirkungen dort, wo sie hingehören: am Rand, als eigene Ströme, die man mit denselben Operatoren komponiert wie alles andere. takeUntil beendet den Timer deklarativ, merge fasst mehrere Quellen zusammen, filter zerlegt einen Strom in Teilströme. Der Lebenszyklus einer Nebenwirkung ist keine Sonderlogik mehr, sondern ein Datenfluss-Ausdruck.

Hier zeigt sich auch ein echter Unterschied zu Elm, kein nachgebauter: Elm-Subscriptions kennen nur next – es kommt die nächste Message, sonst nichts. Fehler und Abschluss laufen als gewöhnliche Message-Inhalte durch update, und für verkettete asynchrone Effekte gibt es Tasks mit andThen. Ein Rx-Observable dagegen trägt drei Signale im Vertrag: next, error, complete. Ein Strom kann scheitern, und ein Strom kann fertig sein – beides ist Teil des Typs, nicht Konvention. Für Dinge wie AJAX-Long-Polling gegen ein CouchDB-Change-Feed oder EventSource-Push (beides als Beispiel im Repository) ist das die natürlichere Beschreibung.

Der gesamte Kreislauf, als Bild:

flowchart LR
  U[Benutzer-Ereignis] --> A["actions$ (Strom)"]
  SUB["subscriptions:<br/>Interval, HTTP, Push"] --> A
  A --> S["scan(update, initialModel)"]
  S --> M["model$ (Strom)"]
  M --> V["React-View<br/>rendert das Model"]
  V --> U

Eine Richtung, ein Kreis. Alles, was passiert, passiert als Wert auf einem Strom – und ist damit beobachtbar, filterbar, protokollierbar. Ein actions$.subscribe(console.log) an der richtigen Stelle ersetzt eine Menge Debugger-Sitzungen.

Redux, Elm, RxJS – dreimal dieselbe Idee

Wer bis hierhin mitgelesen hat und Redux kennt, hat die Pointe längst gesehen: update ist ein Reducer. (model, action) => model und (state, action) => state sind dieselbe Signatur mit anderen Namen. Das ist kein Zufall – Dan Abramov hat nie einen Hehl daraus gemacht, dass Redux die Elm-Architektur für JavaScript destilliert. Der Unterschied liegt darin, was jede Variante der Idee erzwingt und was sie nur empfiehlt.

Redux macht die Faltung mit weniger Mathematik und mehr Konvention: ein Store statt eines Model-Stroms, dispatch statt onNext, Middleware statt Operator-Ketten. Dass Reducer rein bleiben und Nebenwirkungen draußen, steht in der Dokumentation – erzwingen kann es niemand. Dafür bekommt man ein Ökosystem: DevTools mit Time-Travel, Bindings, Konventionen, die ein Team ohne Diskussion übernehmen kann. Interessanterweise wächst gerade mit redux-observable eine Middleware heran, die Rx-Ströme als Nebenwirkungsschicht in Redux zurückholt – Actions rein, Actions raus, dazwischen Observable-Operatoren. Die beiden Welten nähern sich also ohnehin an.

Elm erzwingt die Form im Compiler. Es gibt keine Möglichkeit, Zustand an update vorbeizuschleusen, keine vergessene Mutation, keine unbehandelte Message-Variante – der Typcheck findet sie. Das ist die härteste, aber auch die unnachgiebigste Variante: Man bekommt die Garantien nur zusammen mit der Sprache.

Und RxJS? RxJS macht etwas explizit, das bei beiden anderen implizit bleibt: die Zeit. Ein Observable ist eine Folge von Werten auf einer Zeitachse, und die Operatoren sprechen offen darüber – debounce verhandelt über Abstände, takeUntil über ein Ende, scan über Geschichte. In Redux ist Zeit ein Nebeneffekt der Dispatch-Reihenfolge; in Elm versteckt sie die Laufzeitumgebung. Im Rx-Nachbau steht sie im Code. Wer einmal gesehen hat, dass „Zustand" nur der aktuelle Stand einer Faltung über der Zeitachse ist, liest danach auch Redux-Code anders. CycleJS treibt denselben Gedanken übrigens noch konsequenter: Dort ist die gesamte Anwendung eine Funktion von Strömen nach Strömen, React inklusive Rendering ersetzt. Und wer die entgegengesetzte Schule sehen will, findet sie in MobX, das Mutation nicht verbannt, sondern beobachtbar macht – ein anderer Vertrag, eine andere Diskussion.

Was der Eigenbau lehrt – und was nicht

Nach ein paar Wochen mit dem Prototyp und seinen sechzehn Beispielen ist meine Bilanz zweigeteilt. Gelernt habe ich mehr als aus jedem Blogartikel über Redux, und zwar Dinge, die unabhängig von der Bibliothek gelten:

  • Zustand als Faltung zu begreifen macht Fehlerbilder erklärbar: Nicht „der State ist falsch", sondern „diese Action-Folge erzeugt dieses Model" – eine überprüfbare Aussage.
  • Eine einzige Richtung im Datenfluss ist keine Ästhetik, sondern der Grund, warum man das System noch lesen kann, wenn es gewachsen ist.
  • Nebenwirkungen sind dann beherrschbar, wenn sie einen expliziten Lebenszyklus haben – egal ob der Task heißt, Subscription oder Observable.
  • update als reine Funktion ist trivial testbar; der Test für den Delayed-Counter im Repository braucht weder DOM noch Browser.

Was der Eigenbau nicht ist: ein Produktionsersatz. Die Warnung im README steht dort aus Überzeugung. Es fehlt alles, was eine Architektur erst im Alltag trägt – durchdachte Fehlerbehandlung über Komponentengrenzen, Werkzeuge, Dokumentation jenseits eines README, und vor allem: andere Leute, die den Code kennen. Ein Framework, das eine Person an ein paar Abenden gebaut hat, ist eine Lernmaschine, kein Fundament. Wer die Elm-Architektur in JavaScript produktiv will, nimmt Redux und bekommt Konventionen samt Community dazu; wer die Garantien will, nimmt Elm und bezahlt mit dem Sprachwechsel. Der Nachbau dazwischen hat genau einen Zweck: verstehen, warum beide funktionieren.

Dazu kommt eine Beobachtung aus dem Bauen selbst: Die Versuchung, aus dem Proof of Concept „noch schnell" ein richtiges Framework zu machen, ist enorm. Jedes Beispiel legt eine weitere Abstraktion nahe, jede Abstraktion einen weiteren Konfigurationspunkt. Ich habe bewusst dagegen gehalten – minimal-invasiv, React-Rendering plus Rx-Komposition, keine Schicht mehr als nötig. Auch das ist eine Lektion: Der Abstand zwischen „lehrreicher Prototyp" und „schlechtes Framework" beträgt ungefähr zwei Wochenenden.

Unterm Strich

Warum altert diese Form so gut? Weil sie fast keine Annahmen über ihre Umgebung trifft. Model, Update, View – das setzt weder eine Sprache voraus noch eine Bibliothek noch ein Rendering-Verfahren. React 15 kann morgen durch etwas anderes ersetzt werden, rx Version 4 durch Version 5, und die Architektur bleibt dieselbe, weil sie aus zwei Zutaten besteht, die älter sind als das Web: einer Faltung und einer Projektion. Frameworks kommen und gehen in diesem Umfeld schnell genug, dass ich das nicht als Floskel meine, sondern als Auswahlkriterium.

Und die Frage vom Anfang – wann ist das passiert? – hat in dieser Form immer eine Antwort. Die Ereignisgeschichte ist kein Nebenprodukt, das man mit Logging rekonstruiert, sondern die primäre Datenstruktur der Anwendung. Alles andere, das Model eingeschlossen, ist eine Ableitung davon. Wenn ich in Reviews künftig vor einer UI sitze, deren Zustand niemand mehr erklären kann, ist das mein erster Vorschlag: nicht mehr Werkzeuge, sondern eine Richtung.

Weiterführende Quellen

Kommentare