Finite State Machines mit React

Avatar of Jon Bellah
Jon Bellah am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Da JavaScript-Anwendungen im Web immer komplexer werden, ist auch die Komplexität des Umgangs mit dem Zustand in diesen Anwendungen gestiegen – Zustand ist die Gesamtheit aller Daten, die eine Anwendung zur Erfüllung ihrer Funktion benötigt. In den letzten Jahren gab es eine Fülle von großartigen Innovationen im Bereich des State-Managements durch Tools wie Redux, MobX und Vuex. Etwas, das nicht so viel Aufmerksamkeit erhalten hat, ist das State-Design.

Was zum Teufel meine ich mit State Design?

Lassen Sie uns die Szene ein wenig vorbereiten. Früher habe ich beim Erstellen einer Anwendung, die Daten von einem Backend-Service abrufen und dem Benutzer anzeigen muss, meinen Zustand so entworfen, dass boolesche Flags für verschiedene Dinge wie isLoading, isSuccess, isError usw. verwendet werden. Mit zunehmender Anzahl von booleschen Flags wächst die Anzahl der möglichen Zustände, die meine Anwendung haben kann, exponentiell – was die Wahrscheinlichkeit erhöht, dass ein Benutzer einen unbeabsichtigten oder Fehlerzustand antrifft.

Um dieses Problem zu lösen, habe ich in den letzten Monaten die Verwendung von endlichen Automaten als eine Möglichkeit erforscht, den Zustand meiner Anwendungen besser zu designen.

Endliche Automaten sind ein mathematisches Berechnungsmodell, das in den frühen 1940er Jahren entwickelt wurde und seit Jahrzehnten für den Bau von Hard- und Software für eine Vielzahl von Technologien verwendet wird.

Ein endlicher Automat kann als eine abstrakte Maschine definiert werden, die zu einem bestimmten Zeitpunkt in genau einem von endlich vielen Zuständen existiert. Praktischer ausgedrückt ist ein Zustandsautomat durch eine Liste von Zuständen gekennzeichnet, wobei jeder Zustand eine endliche, deterministische Menge von Zuständen definiert, zu denen durch eine gegebene Aktion übergegangen werden kann.

Aufgrund dieser endlichen und deterministischen Natur können wir Zustandsdiagramme verwenden, um unsere Anwendung zu visualisieren – vor oder nach ihrer Erstellung.

Wenn wir beispielsweise einen Authentifizierungs-Workflow visualisieren möchten, könnten wir drei übergeordnete Zustände haben, in denen sich unsere Anwendung für einen Benutzer befinden könnte: eingeloggt, ausgeloggt oder lädt.

Ein Zustandsdiagramm, das eine Anwendung von ausgeloggt über lädt zu eingeloggt zeigt.

Zustandsautomaten sind aufgrund ihrer Vorhersehbarkeit besonders beliebt in Anwendungen, bei denen Zuverlässigkeit entscheidend ist – wie in der Luftfahrtsoftware, im verarbeitenden Gewerbe und sogar im NASA Space Launch System. Auch in der Spieleentwicklungs-Community sind sie seit Jahrzehnten ein fester Bestandteil.

In diesem Artikel werden wir etwas bauen, das die meisten Webanwendungen verwenden: Authentifizierung. Wir werden uns dabei an das obige Zustandsdiagramm halten.

Bevor wir beginnen, machen wir uns jedoch mit einigen der Bibliotheken und APIs vertraut, die wir zum Erstellen dieser Anwendung verwenden werden.

React Context API

React 16.3 führte eine neue, stabile Version der Context API ein. Wenn Sie bisher viel mit React gearbeitet haben, sind Sie vielleicht damit vertraut, wie Daten über Props von Eltern- zu Kindkomponenten übergeben werden. Wenn Sie bestimmte Daten haben, die von einer Vielzahl von Komponenten benötigt werden, können Sie am Ende das tun, was als Prop-Drilling bezeichnet wird – Daten durch mehrere Ebenen des Komponentbaums weitergeben, um die Daten zu einer Komponente zu bringen, die sie benötigt.

Context hilft bei der Bewältigung der Schmerzen des Prop-Drillings, indem es eine Möglichkeit bietet, Daten zwischen Komponenten zu teilen, ohne diese Daten explizit durch den Komponentbaum weitergeben zu müssen, was es perfekt für die Speicherung von Authentifizierungsdaten macht.

Wenn wir Kontext erstellen, erhalten wir ein Provider und ein Consumer-Paar. Der Provider fungiert als "intelligente", zustandsbehaftete Komponente, die unsere Zustandsautomaten-Definition enthält und den aktuellen Zustand unserer Anwendung aufzeichnet.

xstate

xstate ist eine JavaScript-Bibliothek für funktionale, zustandslose endliche Automaten und Statecharts – sie bietet uns eine schöne, saubere API zur Verwaltung von Definitionen und Übergängen durch unsere Zustände.

Eine zustandslose Bibliothek für endliche Automaten mag etwas seltsam klingen, aber im Wesentlichen bedeutet sie, dass xstate sich nur um den Zustand und den Übergang kümmert, den Sie ihm übergeben – das heißt, es liegt an Ihrer Anwendung, ihren eigenen aktuellen Zustand zu verfolgen.

xstate hat viele erwähnenswerte Funktionen, die wir in diesem Artikel nicht ausführlich behandeln werden (da wir nur an der Oberfläche von Statecharts kratzen werden): hierarchische Automaten, parallele Automaten, Verlaufzustände und Guards, um nur einige zu nennen.

Der Ansatz

Nachdem wir nun eine kleine Einführung in Context und xstate hatten, sprechen wir über den Ansatz, den wir verfolgen werden.

Wir beginnen mit der Definition des Kontexts für unsere Anwendung und erstellen dann eine zustandsbehaftete <App />-Komponente (unser Provider), die unseren Authentifizierungs-Zustandsautomaten zusammen mit Informationen über den aktuellen Benutzer und eine Methode für den Benutzer zum Abmelden enthält.

Um die Bühne etwas zu bereiten, werfen wir einen kurzen Blick auf eine CodePen-Demo dessen, was wir bauen werden.

Siehe den Pen Authentication state machine example von Jon Bellah (@jonbellah) auf CodePen.

Also, ohne weitere Umschweife, tauchen wir in den Code ein!

Definition unseres Kontexts

Als erstes müssen wir unseren Anwendungs-Kontext definieren und ihn mit einigen Standardwerten einrichten. Standardwerte im Kontext sind hilfreich, damit wir Komponenten isoliert testen können, da die Standardwerte nur verwendet werden, wenn kein übereinstimmender Provider vorhanden ist.

Für unsere Anwendung werden wir einige Standardwerte festlegen: authState, was den Authentifizierungsstatus des aktuellen Benutzers darstellt, ein Objekt namens user, das Daten über unseren Benutzer enthält, wenn er authentifiziert ist, und dann eine logout()-Methode, die überall in der App aufgerufen werden kann, wenn der Benutzer authentifiziert ist.

const Auth = React.createContext({
  authState: 'login',
  logout: () => {},
  user: {},
});

Definition unseres Automaten

Wenn wir darüber nachdenken, wie die Authentifizierung in einer Anwendung funktioniert, gibt es in ihrer einfachsten Form drei Hauptzustände: ausgeloggt, eingeloggt und lädt. Dies sind die drei Zustände, die wir zuvor diagrammiert haben.

Wenn wir auf das Zustandsdiagramm zurückblicken, besteht unser Automat aus denselben drei Zuständen: ausgeloggt, eingeloggt und lädt. Wir haben auch vier verschiedene Aktionstypen, die ausgelöst werden können: SUBMIT, SUCCESS, FAIL und LOGOUT.

Wir können dieses Verhalten wie folgt im Code modellieren

const appMachine = Machine({
  initial: 'loggedOut',
  states: {
    loggedOut: {
      onEntry: ['error'],
      on: {
        SUBMIT: 'loading',
      },
    },
    loading: {
      on: {
        SUCCESS: 'loggedIn',
        FAIL: 'loggedOut',
      },
    },
    loggedIn: {
      onEntry: ['setUser'],
      onExit: ['unsetUser'],
      on: {
        LOGOUT: 'loggedOut',
      },
    },
  },
});

Wir haben also gerade das Diagramm von zuvor in Code ausgedrückt, aber sind Sie bereit, dass ich Ihnen ein kleines Geheimnis verrate? Dieses Diagramm wurde aus diesem Code mit der xviz-Bibliothek von David Khourshid generiert – die verwendet werden kann, um den tatsächlichen Code, der unsere Zustandsautomaten antreibt, visuell zu erkunden.

Wenn Sie daran interessiert sind, sich tiefer mit komplexen Benutzeroberflächen unter Verwendung von endlichen Automaten zu befassen, hat David Khourshid hier auf CSS-Tricks einen verwandten Artikel, der es wert ist, angeschaut zu werden.

Dies kann ein unglaublich mächtiges Werkzeug sein, wenn Sie versuchen, problematische Zustände in Ihrer Anwendung zu debuggen.

Wenn wir uns nun wieder auf den obigen Code beziehen, definieren wir unseren anfänglichen Anwendungszustand – den wir loggedOut nennen, da wir beim ersten Besuch den Login-Bildschirm anzeigen möchten.

Beachten Sie, dass Sie in einer typischen Anwendung wahrscheinlich vom Ladezustand ausgehen und feststellen würden, ob der Benutzer zuvor authentifiziert war… aber da wir den Anmeldevorgang fälschen, beginnen wir vom Zustand „ausgeloggt“.

Im states-Objekt definieren wir jeden unserer Zustände zusammen mit den entsprechenden Aktionen und Übergängen für jeden dieser Zustände. Dann übergeben wir all das als Objekt an die Funktion Machine(), die aus xstate importiert wird.

Zusammen mit unseren Zuständen loggedOut und loggedIn haben wir einige Aktionen definiert, die wir auslösen möchten, wenn unsere Anwendung diese Zustände betritt oder verlässt. Wir werden später sehen, was diese Aktionen tun.

Das ist unser Zustandsautomat.

Um es noch einmal zu erklären, betrachten wir die Zeile loggedOut: { on: { SUBMIT: 'loading'} }. Das bedeutet, wenn sich unsere Anwendung im Zustand loggedOut befindet und wir unsere Übergangsfunktion mit der Aktion SUBMIT aufrufen, wird unsere Anwendung immer vom Zustand loggedOut in den Zustand loading übergehen. Diesen Übergang können wir durch Aufrufen von appMachine.transition('loggedOut', 'SUBMIT') erreichen.

Von dort aus wird der Ladezustand den Benutzer entweder als authentifizierten Benutzer weiterleiten oder ihn zurück zum Login-Bildschirm schicken und eine Fehlermeldung anzeigen.

Erstellung unseres Kontext-Providers

Der Kontext-Provider wird die Komponente sein, die sich auf der obersten Ebene unserer Anwendung befindet und alle Daten im Zusammenhang mit einem authentifizierten – oder unauthentifizierten – Benutzer beherbergt.

Arbeiten wir in derselben Datei wie unsere Zustandsautomatendefinition, erstellen wir eine <App />-Komponente und richten sie mit allem ein, was wir brauchen. Keine Sorge, wir werden gleich besprechen, was jede Methode tut.

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      authState: appMachine.initialState.value,
      error: '',
      logout: e => this.logout(e),
      user: {},
    };
  }

  transition(event) {
    const nextAuthState = appMachine.transition(this.state.authState, event.type);
    const nextState = nextAuthState.actions.reduce(
      (state, action) => this.command(action, event) || state,
      undefined,
    );
    this.setState({
      authState: nextAuthState.value,
      ...nextState,
    });
  }

  command(action, event) {
    switch (action) {
      case 'setUser':
        if (event.username) {
          return { user: { name: event.username } };
        }
        break;
      case 'unsetUser':
        return {
          user: {},
        };
      case 'error':
        if (event.error) {
          return {
            error: event.error,
          };
        }
        break;
      default:
        break;
    }
  }

  logout(e) {
    e.preventDefault();
    this.transition({ type: 'LOGOUT' });
  }

  render() {
    return (
      <Auth.Provider value={this.state}>
        <div className="w5">
          <div className="mb2">{this.state.error}</div>
          {this.state.authState === 'loggedIn' ? (
            <Dashboard />
          ) : (
            <Login transition={event => this.transition(event)} />
          )}
        </div>
      </Auth.Provider>
    );
  }
}

Puh, das war viel Code! Zerlegen wir ihn in überschaubare Blöcke, indem wir jede Methode dieser Klasse einzeln betrachten.

Im constructor() setzen wir den Zustand unserer Komponente auf den Anfangszustand unseres appMachine und binden die logout-Funktion in den Zustand, damit sie über unseren Anwendungs-Kontext an jeden Konsumenten weitergegeben werden kann, der sie benötigt.

In der Methode transition() tun wir einige wichtige Dinge. Erstens übergeben wir unseren aktuellen Anwendungszustand und den Ereignistyp oder die Aktion an xstate, um unseren nächsten Zustand zu bestimmen. Dann nehmen wir in nextState alle Aktionen, die mit diesem nächsten Zustand verbunden sind (was eine unserer onEntry- oder onExit-Aktionen sein wird) und führen sie durch die Methode command() – dann nehmen wir alle Ergebnisse und setzen unseren neuen Anwendungszustand.

In der Methode command() haben wir eine switch-Anweisung, die ein Objekt zurückgibt – abhängig vom Aktionstyp – das wir verwenden, um Daten in unseren Anwendungszustand einzufügen. Auf diese Weise können wir, sobald ein Benutzer authentifiziert ist, relevante Details über diesen Benutzer – Benutzername, E-Mail, ID usw. – in unseren Kontext einfügen und ihn für alle unsere Konsumentenkomponenten verfügbar machen.

Schließlich definieren wir in unserer render()-Methode unsere Provider-Komponente und übergeben unseren gesamten aktuellen Zustand über die value-Props, was den Zustand für alle Komponenten darunter im Komponentbaum verfügbar macht. Dann rendern wir je nach Zustand unserer Anwendung entweder das Dashboard oder das Login-Formular für den Benutzer.

In diesem Fall haben wir einen ziemlich flachen Komponentbaum unter unserem Provider (Auth.Provider), aber denken Sie daran, dass Kontext diesen Wert für jede Komponente unter unserem Provider im Komponentbaum verfügbar macht, unabhängig von der Tiefe. Wenn wir also beispielsweise eine Komponente haben, die drei oder vier Ebenen tief verschachtelt ist und wir den aktuellen Benutzernamen anzeigen möchten, können wir diesen einfach aus dem Kontext abrufen, anstatt ihn bis zu dieser einen Komponente zu bohren.

Erstellung von Kontext-Konsumenten

Lassen Sie uns nun einige Komponenten erstellen, die unseren Anwendungs-Kontext konsumieren. Von diesen Komponenten aus können wir alle möglichen Dinge tun.

Wir können mit dem Erstellen einer Login-Komponente für unsere Anwendung beginnen.

class Login extends Component {
  constructor(props) {
    super(props);
    this.state = {
      yourName: '',
    }
    this.handleInput = this.handleInput.bind(this);
  }

  handleInput(e) {
    this.setState({
      yourName: e.target.value,
    });
  }

  login(e) {
    e.preventDefault();
    this.props.transition({ type: 'SUBMIT' });
    setTimeout(() => {
      if (this.state.yourName) {
        return this.props.transition({
          type: 'SUCCESS',
          username: this.state.yourName,
        }, () => {
          this.setState({ username: '' });
        });
      }
      return this.props.transition({
        type: 'FAIL',
        error: 'Uh oh, you must enter your name!',
      });
    }, 2000);
  }

  render() {
    return (
      <Auth.Consumer>
        {({ authState }) => (
          <form onSubmit={e => this.login(e)}>
            <label htmlFor="yourName">
              <span>Your name</span>
              <input
                id="yourName"
                name="yourName"
                type="text"
                value={this.state.yourName}
                onChange={this.handleInput}
              />
            </label>
            <input
              type="submit"
              value={authState === 'loading' ? 'Logging in...' : 'Login' }
              disabled={authState === 'loading' ? true : false}
            />
          </form>
        )}
      </Auth.Consumer>
    );
  }
}

Oh je! Das war wieder ein großer Codeblock, also gehen wir jede Methode noch einmal durch.

Im constructor() deklarieren wir unseren Standardzustand und binden die Methode handleInput(), damit sie intern auf das richtige this verweist.

In handleInput() nehmen wir den Wert unseres Formularfeldes aus unserer render()-Methode und setzen diesen Wert im Zustand – dies wird als kontrolliertes Formular bezeichnet.

Die Methode login() ist dort, wo Sie normalerweise Ihre Authentifizierungslogik platzieren würden. Im Fall dieser App simulieren wir nur eine Verzögerung mit setTimeout() und authentifizieren entweder den Benutzer – wenn er einen Namen angegeben hat – oder geben einen Fehler zurück, wenn das Feld leer gelassen wurde. Beachten Sie, dass die von ihm aufgerufene transition()-Funktion tatsächlich diejenige ist, die wir in unserer <App />-Komponente definiert haben und die über Props weitergegeben wurde.

Schließlich zeigt unsere render()-Methode unser Login-Formular an, aber beachten Sie, dass die Komponente <Login /> auch ein Kontext-Konsument ist. Wir verwenden den authState-Kontext, um zu bestimmen, ob wir unseren Login-Button in einem deaktivierten Ladezustand anzeigen sollen oder nicht.

Kontext tief im Komponentbaum verwenden

Nachdem wir die Erstellung unseres Zustandsautomaten und eine Möglichkeit für Benutzer, sich in unserer Anwendung anzumelden, behandelt haben, können wir uns nun darauf verlassen, dass wir Informationen über diesen Benutzer in jeder Komponente haben, die unter unserer Komponente <Dashboard /> verschachtelt ist – da sie nur gerendert wird, wenn der Benutzer angemeldet ist.

Erstellen wir also eine zustandslose Komponente, die den Benutzernamen des aktuell authentifizierten Benutzers abruft und eine Willkommensnachricht anzeigt. Da wir die logout()-Methode an alle unsere Konsumenten übergeben, können wir dem Benutzer auch von überall im Komponentbaum die Möglichkeit geben, sich abzumelden.

const Dashboard = () => (
  <Auth.Consumer>
    {({ user, logout }) => (
      <div>
        <div>Hello {user.name}</div>
        <button onClick={e => logout(e)}>
          Logout
        </button>
      </div>
    )}
  </Auth.Consumer>
);

Größere Anwendungen mit Statecharts bauen

Die Verwendung von endlichen Automaten mit React muss nicht auf die Authentifizierung beschränkt sein und auch nicht auf die Context API.

Mit Statecharts können Sie hierarchische Automaten und/oder parallele Automaten haben – das bedeutet, dass einzelne React-Komponenten ihren eigenen internen Zustandsautomaten haben können, aber dennoch mit dem Gesamtzustand Ihrer Anwendung verbunden sind.

In diesem Artikel haben wir uns hauptsächlich darauf konzentriert, xstate direkt mit der nativen Context API zu verwenden; für größere Anwendungen empfehle ich dringend, sich react-automata anzusehen, das eine dünne Abstraktionsschicht über xstate bietet. react-automata hat den zusätzlichen Vorteil, dass es automatisch Jest-Tests für Ihre Komponenten generieren kann.

Zustandsautomaten und State-Management-Tools sind nicht gegenseitig ausschließend

Es ist leicht, sich zu täuschen und zu denken, man müsse entweder xstate oder Redux verwenden; aber es ist wichtig zu beachten, dass Zustandsautomaten eher ein Implementierungskonzept sind, das sich damit beschäftigt, wie man seinen Zustand designt – nicht unbedingt, wie man ihn verwaltet.

Tatsächlich können Zustandsautomaten mit fast jedem ungezwungenen State-Management-Tool verwendet werden. Ich ermutige Sie, verschiedene Ansätze zu erkunden, um festzustellen, was für Sie, Ihr Team und Ihre Anwendung(en) am besten funktioniert.

Zusammenfassend

Diese Konzepte können auf die reale Welt übertragen werden, ohne Ihre gesamte Anwendung refaktorieren zu müssen. Zustandsautomaten sind ein ausgezeichnetes Refactoring-Ziel – das bedeutet, wenn Sie das nächste Mal an einer Komponente arbeiten, die mit booleschen Flags wie isFetching und isError übersät ist, sollten Sie in Erwägung ziehen, diese Komponente mit einem Zustandsautomaten zu refaktorieren.

Als Frontend-Entwickler stelle ich fest, dass ich oft einen von zwei Kategorien von Fehlern behebe: Anzeigebezogene Probleme oder unerwartete Anwendungszustände.

Zustandsautomaten lassen die zweite Kategorie praktisch verschwinden.

Wenn Sie daran interessiert sind, sich tiefer mit Zustandsautomaten zu befassen, habe ich in den letzten Monaten an einem Kurs über endliche Zustandsautomaten gearbeitet; wenn Sie sich für die E-Mail-Liste anmelden, erhalten Sie einen Rabattcode, wenn der Kurs im August startet.