Tutoriel Vidéo JavaScript Machine à états finis avec Robot

Télécharger la vidéo

Dans cette vidéo je vous propose de découvrir le principe des machines à états finis avec la librairie Robot.

La problématique

Comme d'habitude il est important de comprendre la problématique avant de s'intérésser à une librairie. Pour cela prenons un exemple concret : Le système d'édition de titre de github.

Interface d'édition de titre sur Github

Cette interface propose au premier abord 2 états que l'on serait tenté de représenter via un simple booléen.

let editMode = true

Quand on clique sur le bouton Edit le booléen change de valeur pour devenir true et il devient false lorsque l'on clique sur Save ou Cancel.

Malheureusement, cette approche est trop naïve. En effet le champs et les boutons doivent être désactivés lorsque l'on clique sur Save pendant que l'on attend le retour de l'API.

let editing = true
let isLoading = true

On doit aussi afficher un indicateur en cas de succès et une erreur en cas de problème.

let hasError = true
let hasSuccess = false

On doit aussi vérifier si la valeur du champs a été modifié pour permettre l'envoie.

let isDirty = true

Le problème maintenant est que vous devez synchroniser tous ces booléen à chaque changement d'état.

const handleSuccess = (newTitle) => {
    setTitle(newTitle)
    setError(false)
    setSuccess(true)
    setDirty(false)
    setLoading(false)
    setEditing(false)
}

Une machine à états finis apporte une nouvelle approche pour déclarer l'état de nos composants. Cette approche permet aussi d'éviter les états invalides et les erreurs provoquées par une mauvaise combinaison de booléen.

La librairie Robot

Maintenant il nous faut une librairie pour décrire notre machine et nous allons nous pencher sur la librairie Robot. On commence par définir les différents états :

import {
  createMachine,
  state
} from "robot3";

export default createMachine({
    idle: state(),
    edit: state(),
    loading: state(),
    success: state(),
    error: state(),
});

Ensuite on va créer des transitions qui permettent de passer d'un état à un autre.

import {
    createMachine,
    guard,
    invoke,
    reduce,
    state,
    transition,
} from "robot3";

export default createMachine(
  {
    idle: state(
        transition("edit", "edit")
    ),
    edit: state(
        transition('cancel', 'idle'),
        transition('submit', 'loading', guard(isTitleValid)),
        transition('input', 'edit', 
            reduce((ctx, ev) => ({...ctx, title: ev.target.value}))
        )
    ),
    loading: invoke(
        syncDataWithServer,
        transition("done", "success"),
        transition("error", "error", 
            reduce((ctx, ev) => ({ ...ctx, error: ev.error.message }))
        )
    ),
    success: invoke(() => wait(2000), transition("done", "idle")),
    error: state(
        transition("dismiss", "edit"),
        transition("retry", "loading"),
    ),
  }
);

Cela peut sembler plus long au premier abord mais cette manière de définir notre système offre plusieurs avantages :

  • Notre système ne peut pas se trouver dans un état invalide.
  • Tous les états possibles sont définis en amont.
  • Les états et les transitions peuvent être validées via robot/debug.

Intégration dans un framework

Une fois votre machine définit vous pouvez l'intégrer facilement dans un framework Front end. On utilisera pour cela la méthode interpret qui permet de créer une nouvelle instance de notre machine. Cette méthode prendra en paramètre une méthode qui permet d'écouter les changements d'états.

// Exemple de hook React / Preact
import { useCallback, useRef, useState } from "react";
import { interpret } from "robot3";

export function useMachine(machine, initialContext = {}) {

  // On crée une nouvelle instance de la machine
  const ref = useRef(null);
  if (ref.current === null) {
    ref.current = interpret(
      machine,
      () => {
        setState(service.machine.current);
        setContext(service.context);
      },
      initialContext
    );
  }
  const service = ref.current;

  // On stocke le context & l'état de la machine dans l'état react
  const [state, setState] = useState(service.machine.current);
  const [context, setContext] = useState(service.context);

  // Permet de demander une transition
  const send = useCallback(
    function (type, params = {}) {
      service.send({ type: type, ...params });
    },
    [service]
  );

  // Vérifie si une transition est possible depuis l'état courant
  const can = useCallback(
    (transitionName) => {
      const transitions = service.machine.state.value.transitions;
      if (!transitions.has(transitionName)) {
        return false;
      }
      const transitionsForName = transitions.get(transitionName);
      for (const t of transitionsForName) {
        if ((t.guards && t.guards(service.context)) || !t.guards) {
          return true;
        }
      }
      return false;
    },
    [service.context, service.machine.state.value.transitions]
  );
  return [state, context, send, can];
}

Enfin voila un exemple de ce que donne le composant d'édition Github avec cette machine.

import React, { useCallback } from "react";
import Box from "./ui/Box";
import Title from "./ui/Title";
import Button from "./ui/Button";
import { useMachine } from "./useMachine";
import machine from "./machine";
import TextField from "./ui/TextField";
import Flex from "./ui/Flex";
import Alert from "./ui/Alert";

export default function EditableTitle({ title }) {
  const [state, context, send, can] = useMachine(machine, { title });
  const editMode = !["idle", "success"].includes(state);
  const dismiss = useCallback(() => {
    send("dismiss");
  }, [send]);

  return (
    <Box p={2}>
      {state === "success" && (
        <Alert severity="success">Le titre a bien été sauvegardé</Alert>
      )}
      {state === "error" && (
        <Alert severity="error" onClose={dismiss}>
          {context.error}
        </Alert>
      )}
      <Flex justifyContent="space-between">
        {!editMode ? (
          <Title>{context.title}</Title>
        ) : (
          <TextField
            id="title"
            disabled={!can("input")}
            defaultValue={context.title}
            onChange={(e) => send("input", { value: e.target.value })}
            fullWidth
          />
        )}
        {editMode ? (
          <Flex>
            <Button
              disabled={!can("submit")}
              color="primary"
              loading={state === "loading"}
              onClick={() => send("submit")}
            >
              Envoyer
            </Button>
            <Button disabled={!can("cancel")} onClick={() => send("cancel")}>
              Annuler
            </Button>
          </Flex>
        ) : (
          <Button onClick={() => send("edit")}>Editer</Button>
        )}
      </Flex>
    </Box>
  );
}