Poznaj zastosowanie maszynie stanowej XState w React

W poprzednich postach opisywałem sposoby na zarządzanie stanem w React -Redux, Context API, MobX czy MobX State Tree – pewne problemy jednak pozostają nierozwiązane.
Powyższe rozwiązania sprawdzą się jako magazyn na dane – nie powiedzą nam jednak nic o systemie. Nie określą w jednoznaczny sposób możliwych stanów jakie wystąpią podczas działania aplikacji, nie walidują poprawności przejścia pomiędzy jednym stanem a drugim – musi o to zadbać ręcznie programista.
Często też wiedza o tych stanach ginie w trakcie rozwoju projektu – w miarę jak przybywa kodu i funkcjonalności, gdy odchodzą programiści znający system i przychodzą nowi, którzy muszą się go uczyć.

W rozwiązaniu tego problemu sprawdzają się skończone maszyny stanowe – definiują skończoną ilość dostępnych stanów i przejścia z jednego stanu w drugi.

Spis treści

  1. Czym jest skończona maszyna stanowa
  2. Biblioteka XState
    1. XState visualizer
    2. Maszyna stanowa
  3. Integracja biblioteki XState z Reactem
  4. Podsumowanie

Czym jest skończona maszyna stanowa

W uproszczeniu o skończonej maszynie stanowej myśleć możesz jako o zestawie wszystkich stanów w jakiej znajduje się model wraz ze zdarzeniami, doprowadzającymi do przejścia z jednego stanu w drugi. Bardzo istotne jest to, że przejścia pomiędzy stanami są jasno określone – ze stanu X można przejść do Y tylko, jeżeli maszyna na to pozwala.

Czemu, jako frontendowiec, w ogóle miałbyś się interesować maszynami stanowymi? Ponieważ uczynią Twoją pracę znacznie prostszą – UI jako maszyna stanowa jest prostszy w zrozumieniu, łatwiej przypilnować przejść z jednego stanu w drugi. Prościej wejść w projekt, jeżeli możesz wejść w jeden plik który opisuje zachowanie danej części systemu zamiast szukać tych informacji po omacku w wielu plikach.
Ponadto, dzięki temu że maszyny stanowe można opisywać diagramami komunikacja z biznesem czy grafikiem za pomocą diagramów będzie dużo bardziej produktywna i zmniejszy się ilość niedomówień.

Biblioteka XState

Jedną z implementacji maszyny stanowej jaką możesz użyć w projekcie jest biblioteka XState.

XState visualizer

Zaletą maszyny stanowej XState (i maszyn stanowych w ogólności) jest to, że możesz ją wizualizować. Biblioteka XState dostarcza narzędzia Visualizer. Możesz nawet na bieżąco podglądać, jak wygląda diagram Twojej maszyny podczas kodowania jej w edytorze na stronie.
Przykładowo maszyna:

const checked = 'checked';
const unchecked = 'unchecked';
const TOGGLE = 'TOGGLE'

const checkbboxMachine = Machine({
  id: 'checkbox',
  initial: unchecked,
  states: {
    [unchecked]: {
      on: {
        [TOGGLE]: checked,
      },
    },
    [checked]: {
      on: {
        [TOGGLE]: unchecked,
      },
    },
  },
});

Wygląda następująco:

W kodzie maszyny celowo użyłem tzw dynamic property key – jeżeli np zdecydowalibyśmy się na zmianę standardów, np nazwa stanu pisana wielkimi literami a nazwa zdarzenia małymi, zmienić trzeba będzie tylko w jednym miejscu. Gorąco zachęcam Cię do takiego podejścia.

Maszyna stanowa

Diagram bardziej skomplikowanej maszyny stanowej mógłby się przedstawiać następująco:

Logika wygląda tak:

  1. Na początku maszyna znajduje się w stanie unathorized
  2. Akcja LOGIN powoduje przejście do stanu loading
  3. Stąd akcja RESOLVE powoduje przejście do stanu success, z którego akcją LOGOUT można przejść z powrotem do stanu unauthorized
  4. Akcja REJECT powoduje przejście do stanu failure, z którego akcja RETRY przeniesie nas do stanu unathorized ale tylko wtedy, jeżeli zostały jakieś próby logowania.
const unauthorized = 'unauthorized';
const LOGIN = 'LOGIN';
const loading = 'loading';
const RESOLVE = 'RESOLVE';
const REJECT = 'REJECT';
const success = 'success';
const failure = 'failure';
const LOGOUT = 'LOGOUT';
const RETRY = 'RETRY';

const authMachine = Machine({
  id: 'auth',
  initial: unauthorized,
  context: {
    retries: 5,
  },
  states: {
    [unauthorized]: {
      on: {
        [LOGIN]: {
          target: loading,
          cond: 'hasRetries',
        },
      },
    },
    [loading]: {
      on: {
        [RESOLVE]: success,
        [REJECT]: failure,
      }
    },
    [success]: {
      on: {
        [LOGOUT]: unauthorized,
      },
    },
    failure: {
      on: {
        [RETRY]: {
          target: unauthorized,
          actions: assign({
            retries: (context, event) => context.retries - 1
          }),
        },
      },
    },
   },
  },
  {
    guards: {
      hasRetries: context => context.retries > 0,
    },
  },
);

Integracja biblioteki XState z Reactem

Stwórz proszę nową aplikację CRA. Po zainstalowaniu biblioteki głównej oraz jej paczki integracji z Reactem:

npm i xstate @xstate/react

Masz do dyspozycji hook zwracający stan oraz funkcję, która umożliwia wysyłanie zdarzeń do maszyny:

const [state, send] = useMachine(authMachine);

state udostępnia funkcję matches do sprawdzania, czy maszyna znajduje się w określonym stanie:

const isLoading = state.matches(loading);

Stwórz UI umożliwiające wysyłanie zdarzeń do maszyny stanowej:

import React from 'react';
import {useMachine} from "@xstate/react";
import {authMachine, failure, loading, LOGIN, LOGOUT, REJECT, RESOLVE, RETRY, success} from "./authMachine";

function App() {
  const [state, send] = useMachine(authMachine)

  return (
    <div className="App">
      <div>
        Current state: {state.value}
      </div>
      <button onClick={() => send(LOGIN)}>Login</button>
      {state.matches(loading) && (
        <p>
          <button onClick={() => send(RESOLVE)}>
            Success
          </button>
          <button onClick={() => send(REJECT)}>
            Failure
          </button>
        </p>
      )}
      {state.matches(success) && (
        <p>
          <button onClick={() => send(LOGOUT)}>Logout</button>
        </p>
      )}
      {state.matches(failure) && (
        <p>
          <button onClick={() => send(RETRY)}>Retry</button>
        </p>
      )}
      <p>
        <button onClick={() => send(RETRY)}>
          Always visible retry
        </button>
      </p>
    </div>
  );
}

export default App;

Kod zakłada, że w pliku authMachine znajduje się zdefiniowana wyżej maszyna stanowa – z tą różnicą, że wszystkie stałe są wyeksportowane, by móc użyć ich w pliku głównym.
Jeżeli już przekopiowałeś, uruchomiłeś i przeklikałeś aplikację zauważysz, że klikając przycisk Always visible retry stan zmienia się tylko wtedy, jeżeli maszyna jest w stanie failure. Dzieje się tak, ponieważ przejścia między stanami muszą być zdefiniowane jawnie – upraszcza to konstrukcję UI.

Podsumowanie

Maszyny stanowe pozwalają grupować w jednym miejscu wiedzę o tym, jak działa system. Ich użycie zwiększa przewidywalność zachowań systemu, ułatwiają też komunikację z osobami nietechnicznymi dzięki temu, że można opisać je jako diagram – szczególnie, że biblioteka XState dostarcza dedykowane wizualizacji narzędzie.
Warto rozważyć ich zastosowanie w projektach frontendowych.