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.
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>
);
}