L'ascesa delle macchine di stato

Pubblicato: 2022-03-10
Riassunto veloce ↬ Lo sviluppo dell'interfaccia utente è diventato difficile negli ultimi due anni. Questo perché abbiamo trasferito la gestione dello stato sul browser. E la gestione dello stato è ciò che rende il nostro lavoro una sfida. Se lo facciamo correttamente, vedremo come la nostra applicazione si ridimensiona facilmente senza bug. In questo articolo vedremo come utilizzare il concetto di macchina a stati per risolvere i problemi di gestione dello stato.

È già il 2018 e innumerevoli sviluppatori front-end stanno ancora conducendo una battaglia contro la complessità e l'immobilità. Mese dopo mese, hanno cercato il Santo Graal: un'architettura applicativa priva di bug che li aiuterà a fornire rapidamente e con alta qualità. Sono uno di quegli sviluppatori e ho trovato qualcosa di interessante che potrebbe aiutare.

Abbiamo fatto un buon passo avanti con strumenti come React e Redux. Tuttavia, non sono sufficienti da soli nelle applicazioni su larga scala. Questo articolo ti introdurrà al concetto di macchine a stati nel contesto dello sviluppo front-end. Probabilmente ne hai già costruiti molti senza rendertene conto.

Un'introduzione alle macchine a stati

Una macchina a stati è un modello matematico di calcolo. È un concetto astratto per cui la macchina può avere diversi stati, ma in un dato momento ne soddisfa solo uno. Esistono diversi tipi di macchine a stati. La più famosa, credo, è la macchina di Turing. È una macchina a stati infiniti, il che significa che può avere un numero infinito di stati. La macchina di Turing non si adatta bene allo sviluppo dell'interfaccia utente di oggi perché nella maggior parte dei casi abbiamo un numero finito di stati. Questo è il motivo per cui le macchine a stati finiti, come Mealy e Moore, hanno più senso.

La differenza tra loro è che la macchina di Moore cambia il suo stato basandosi solo sul suo stato precedente. Sfortunatamente, abbiamo molti fattori esterni, come le interazioni degli utenti e i processi di rete, il che significa che nemmeno la macchina Moore è abbastanza buona per noi. Quello che stiamo cercando è la macchina Mealy. Ha uno stato iniziale e quindi passa a nuovi stati in base all'input e al suo stato attuale.

Altro dopo il salto! Continua a leggere sotto ↓

Uno dei modi più semplici per illustrare come funziona una macchina a stati è guardare un tornello. Ha un numero finito di stati: bloccato e sbloccato. Ecco un semplice grafico che ci mostra questi stati, con i loro possibili input e transizioni.

tornello

Lo stato iniziale del tornello è bloccato. Non importa quante volte possiamo spingerlo, rimane in quello stato bloccato. Tuttavia, se gli passiamo una moneta, passa allo stato sbloccato. Un'altra moneta a questo punto non farebbe nulla; sarebbe ancora nello stato sbloccato. Una spinta dall'altra parte funzionerebbe e saremmo in grado di passare. Questa azione porta anche la macchina allo stato bloccato iniziale.

Se volessimo implementare un'unica funzione che controlla il tornello, probabilmente ci troveremmo con due argomenti: lo stato attuale e un'azione. E se usi Redux, questo probabilmente ti suona familiare. È simile alla nota funzione di riduzione, in cui riceviamo lo stato corrente e, in base al carico utile dell'azione, decidiamo quale sarà lo stato successivo. Il riduttore è la transizione nel contesto delle macchine a stati. In effetti, qualsiasi applicazione che ha uno stato che possiamo in qualche modo cambiare può essere chiamata macchina a stati. È solo che stiamo implementando tutto manualmente più e più volte.

In che modo una macchina a stati è migliore?

Al lavoro, utilizziamo Redux e ne sono abbastanza soddisfatto. Tuttavia, ho iniziato a vedere schemi che non mi piacciono. Con "non mi piace", non intendo che non funzionino. È più che aggiungono complessità e mi obbligano a scrivere più codice. Ho dovuto intraprendere un progetto parallelo in cui avevo spazio per sperimentare e ho deciso di ripensare alle nostre pratiche di sviluppo React e Redux. Ho iniziato a prendere appunti sulle cose che mi riguardavano e mi sono reso conto che un'astrazione della macchina a stati avrebbe davvero risolto alcuni di questi problemi. Entriamo e vediamo come implementare una macchina a stati in JavaScript.

Attaccheremo un problema semplice. Vogliamo recuperare i dati da un'API back-end e mostrarli all'utente. Il primo passo è imparare a pensare negli stati, piuttosto che nelle transizioni. Prima di entrare nelle macchine a stati, il mio flusso di lavoro per la creazione di una tale funzionalità era simile a questo:

  • Visualizziamo un pulsante di recupero dati.
  • L'utente fa clic sul pulsante di recupero dati.
  • Invia la richiesta al back-end.
  • Recupera i dati e analizzali.
  • Mostralo all'utente.
  • Oppure, se si verifica un errore, visualizzare il messaggio di errore e mostrare il pulsante di recupero dati in modo da poter attivare nuovamente il processo.
pensiero lineare

Stiamo pensando in modo lineare e fondamentalmente cercando di coprire tutte le possibili direzioni per il risultato finale. Un passaggio tira l'altro e rapidamente inizieremmo a ramificare il nostro codice. Che dire di problemi come l'utente che fa doppio clic sul pulsante o l'utente che fa clic sul pulsante mentre stiamo aspettando la risposta del back-end, o la richiesta ha esito positivo ma i dati sono danneggiati. In questi casi, avremmo probabilmente varie bandiere che ci mostrano cosa è successo. Avere flag significa più clausole if e, nelle app più complesse, più conflitti.

pensiero lineare

Questo perché stiamo pensando per transizioni. Ci stiamo concentrando su come avvengono queste transizioni e in quale ordine. Concentrarsi invece sui vari stati dell'applicazione sarebbe molto più semplice. Quanti stati abbiamo e quali sono i loro possibili input? Usando lo stesso esempio:

  • oziare
    In questo stato, visualizziamo il pulsante di recupero dati, ci sediamo e aspettiamo. L'azione possibile è:
    • clic
      Quando l'utente fa clic sul pulsante, stiamo inviando la richiesta al back-end e quindi trasferiamo la macchina in uno stato di "recupero".
  • andare a prendere
    La richiesta è in volo e ci sediamo e aspettiamo. Le azioni sono:
    • successo
      I dati arrivano correttamente e non sono danneggiati. Usiamo i dati in qualche modo e torniamo allo stato "inattivo".
    • fallimento
      Se si verifica un errore durante la richiesta o l'analisi dei dati, si passa allo stato di "errore".
  • errore
    Mostriamo un messaggio di errore e visualizziamo il pulsante di recupero dati. Questo stato accetta un'azione:
    • riprovare
      Quando l'utente fa clic sul pulsante Riprova, attiviamo nuovamente la richiesta e passiamo la macchina allo stato di "recupero".

Abbiamo descritto più o meno gli stessi processi, ma con stati e input.

macchina a stati

Questo semplifica la logica e la rende più prevedibile. Risolve anche alcuni dei problemi sopra menzionati. Nota che, mentre siamo nello stato di "recupero", non accettiamo alcun clic. Quindi, anche se l'utente fa clic sul pulsante, non accadrà nulla perché la macchina non è configurata per rispondere a quell'azione mentre si trova in quello stato. Questo approccio elimina automaticamente la ramificazione imprevedibile della nostra logica del codice. Ciò significa che avremo meno codice da coprire durante il test . Inoltre, alcuni tipi di test, come i test di integrazione, possono essere automatizzati. Pensa a come avremmo un'idea molto chiara di ciò che fa la nostra applicazione e potremmo creare uno script che esamini gli stati e le transizioni definiti e che generi asserzioni. Queste affermazioni potrebbero dimostrare che abbiamo raggiunto ogni stato possibile o percorso un viaggio particolare.

In effetti, scrivere tutti i possibili stati è più facile che scrivere tutte le possibili transizioni perché sappiamo quali stati abbiamo bisogno o abbiamo. A proposito, nella maggior parte dei casi, gli stati descrivono la logica di business della nostra applicazione, mentre le transizioni sono molto spesso sconosciute all'inizio. I bug nel nostro software sono il risultato di azioni inviate in uno stato sbagliato e/o al momento sbagliato. Lasciano la nostra app in uno stato di cui non siamo a conoscenza e questo interrompe il nostro programma o lo fa comportare in modo errato. Ovviamente non vogliamo trovarci in una situazione del genere. Le macchine a stati sono dei buoni firewall . Ci proteggono dal raggiungere stati sconosciuti perché stabiliamo dei limiti per ciò che può accadere e quando, senza dire esplicitamente come. Il concetto di macchina a stati si abbina molto bene con un flusso di dati unidirezionale. Insieme, riducono la complessità del codice e chiariscono il mistero dell'origine di uno stato.

Creazione di una macchina a stati in JavaScript

Basta parlare: vediamo un po' di codice. Useremo lo stesso esempio. Sulla base dell'elenco sopra, inizieremo con quanto segue:

 const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } } }

Abbiamo gli stati come oggetti e i loro possibili input come funzioni. Manca lo stato iniziale, però. Cambiamo il codice sopra in questo:

 const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } } }

Una volta definiti tutti gli stati che hanno senso per noi, siamo pronti per inviare l'input e cambiare stato. Lo faremo utilizzando i due metodi di supporto seguenti:

 const machine = { dispatch(actionName, ...payload) { const actions = this.transitions[this.state]; const action = this.transitions[this.state][actionName]; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ... }

La funzione di dispatch controlla se c'è un'azione con il nome dato nelle transizioni dello stato corrente. In tal caso, lo spara con il carico utile specificato. Stiamo anche chiamando il gestore action con la machine come contesto, in modo da poter inviare altre azioni con this.dispatch(<action>) o modificare lo stato con this.changeStateTo(<new state>) .

Seguendo il percorso dell'utente del nostro esempio, la prima azione che dobbiamo inviare è click . Ecco come appare il gestore di tale azione:

 transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ... } machine.dispatch('click');

Per prima cosa cambiamo lo stato della macchina in fetching . Quindi, attiviamo la richiesta al back-end. Supponiamo di avere un servizio con un metodo getData che restituisce una promessa. Una volta che è stato risolto e l'analisi dei dati è corretta, inviamo success , se non failure .

Fin qui tutto bene. Successivamente, dobbiamo implementare azioni e input di success e failure nello stato di fetching :

 transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ... }

Nota come abbiamo liberato il nostro cervello dal dover pensare al processo precedente. Non ci interessano i clic degli utenti o cosa sta succedendo con la richiesta HTTP. Sappiamo che l'applicazione è in uno stato di fetching e ci aspettiamo solo queste due azioni. È un po' come scrivere una nuova logica in isolamento.

L'ultimo bit è lo stato di error . Sarebbe bello se avessimo fornito quella logica di ripetizione in modo che l'applicazione possa riprendersi da un errore.

 transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } } }

Qui dobbiamo duplicare la logica che abbiamo scritto nel gestore dei click . Per evitarlo, dovremmo definire il gestore come una funzione accessibile a entrambe le azioni, oppure passare prima allo stato idle e quindi inviare manualmente l'azione di click .

Un esempio completo della macchina a stati di lavoro può essere trovato nel mio Codepen.

Gestire le macchine a stati con una libreria

Il modello della macchina a stati finiti funziona indipendentemente dal fatto che utilizziamo React, Vue o Angular. Come abbiamo visto nella sezione precedente, possiamo facilmente implementare una macchina a stati senza troppi problemi. Tuttavia, a volte una libreria offre maggiore flessibilità. Alcuni di quelli buoni sono Machina.js e XState. In questo articolo, tuttavia, parleremo di Stent, la mia libreria simile a Redux che integra il concetto di macchine a stati finiti.

Stent è un'implementazione di un contenitore di macchine a stati. Segue alcune delle idee nei progetti Redux e Redux-Saga, ma fornisce, a mio parere, processi più semplici e privi di standard. È sviluppato utilizzando lo sviluppo guidato dal readme e ho letteralmente trascorso settimane solo sulla progettazione dell'API. Poiché stavo scrivendo la libreria, ho avuto la possibilità di risolvere i problemi che ho riscontrato durante l'utilizzo delle architetture Redux e Flux.

Creazione di macchine

Nella maggior parte dei casi, le nostre applicazioni coprono più domini. Non possiamo andare con una sola macchina. Quindi, Stent consente la creazione di molte macchine:

 import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });

Successivamente, possiamo accedere a queste macchine utilizzando il metodo Machine.get :

 const machineA = Machine.get('A'); const machineB = Machine.get('B');

Collegare le macchine alla logica di rendering

Il rendering nel mio caso viene eseguito tramite React, ma possiamo utilizzare qualsiasi altra libreria. Si riduce all'attivazione di un callback in cui si attiva il rendering. Una delle prime funzionalità su cui ho lavorato è stata la funzione di connect :

 import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });

Diciamo quali macchine sono importanti per noi e diamo i loro nomi. Il callback che passiamo alla map viene attivato inizialmente una volta e poi successivamente ogni volta che lo stato di alcune macchine cambia. È qui che attiviamo il rendering. A questo punto, abbiamo accesso diretto alle macchine connesse, in modo da poter recuperare lo stato e i metodi correnti. Ci sono anche mapOnce , per ottenere il callback attivato solo una volta, e mapSilent , per saltare l'esecuzione iniziale.

Per comodità, viene esportato un helper specifico per l'integrazione di React. È molto simile a connect(mapStateToProps) di Redux.

 import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });

Stent esegue il nostro callback di mappatura e si aspetta di ricevere un oggetto, un oggetto che viene inviato come props di scena al nostro componente React.

Che cos'è lo stato nel contesto dello stent?

Finora, il nostro stato è stato dei semplici fili. Sfortunatamente, nel mondo reale, dobbiamo mantenere più di una stringa nello stato. Questo è il motivo per cui lo stato di Stent è in realtà un oggetto con delle proprietà all'interno. L'unica proprietà riservata è name . Tutto il resto sono dati specifici dell'app. Per esempio:

 { name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }

La mia esperienza con Stent finora mi mostra che se l'oggetto stato diventa più grande, probabilmente avremmo bisogno di un'altra macchina che gestisca quelle proprietà aggiuntive. Identificare i vari stati richiede del tempo, ma credo che questo sia un grande passo avanti nella scrittura di applicazioni più gestibili. È un po' come predire il futuro e tracciare i quadri delle possibili azioni.

Lavorare con la macchina a stati

Simile all'esempio all'inizio, dobbiamo definire i possibili stati (finiti) della nostra macchina e descrivere i possibili input:

 import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });

Abbiamo il nostro stato iniziale, idle , che accetta un'azione run . Una volta che la macchina è in running , siamo in grado di attivare l'azione di stop , che ci riporta allo stato idle .

Probabilmente ricorderai gli helper dispatch e changeStateTo dalla nostra implementazione in precedenza. Questa libreria fornisce la stessa logica, ma è nascosta internamente e non dobbiamo pensarci. Per comodità, in base alla proprietà transitions , Stent genera quanto segue:

  • metodi di supporto per verificare se la macchina è in uno stato particolare — lo stato idle produce il metodo isIdle() , mentre per running abbiamo isRunning() ;
  • metodi di supporto per l'invio di azioni: runPlease() e stopNow() .

Quindi, nell'esempio sopra, possiamo usare questo:

 machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action

Combinando i metodi generati automaticamente con la funzione di utilità di connect , siamo in grado di chiudere il cerchio. Un'interazione dell'utente attiva l'input e l'azione della macchina, che aggiorna lo stato. A causa di tale aggiornamento, la funzione di mappatura passata per la connect viene attivata e veniamo informati del cambiamento di stato. Quindi, eseguiamo il rendering.

Gestori di input e azioni

Probabilmente il bit più importante sono i gestori di azioni. Questo è il luogo in cui scriviamo la maggior parte della logica dell'applicazione perché stiamo rispondendo all'input e agli stati modificati. Qualcosa che mi piace molto in Redux è integrato anche qui: l'immutabilità e la semplicità della funzione di riduzione. L'essenza del gestore dell'azione di Stent è la stessa. Riceve lo stato corrente e il carico utile dell'azione e deve restituire il nuovo stato. Se il gestore non restituisce nulla ( undefined ), lo stato della macchina rimane lo stesso.

 transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }

Supponiamo di dover recuperare i dati da un server remoto. Lanciamo la richiesta e passiamo la macchina a uno stato di fetching . Una volta che i dati provengono dal back-end, avviiamo un'azione di success , in questo modo:

 machine.success({ label: '...' });

Quindi, torniamo a uno stato idle e manteniamo alcuni dati sotto forma di array todos . Ci sono un paio di altri possibili valori da impostare come gestori di azioni. Il primo e il caso più semplice è quando passiamo solo una stringa che diventa il nuovo stato.

 transitions: { 'idle': { 'run': 'running' } }

Questa è una transizione da { name: 'idle' } a { name: 'running' } usando l'azione run() . Questo approccio è utile quando abbiamo transizioni di stato sincrone e non abbiamo metadati. Quindi, se manteniamo qualcos'altro nello stato, quel tipo di transizione lo staccherà. Allo stesso modo, possiamo passare direttamente un oggetto di stato:

 transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: [] } } }

Stiamo passando dalla editing deleteAllTodos idle

Abbiamo già visto il gestore della funzione e l'ultima variante del gestore dell'azione è una funzione generatore. È ispirato al progetto Redux-Saga e si presenta così:

 import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } } });

Se non hai esperienza con i generatori, questo potrebbe sembrare un po' criptico. Ma i generatori in JavaScript sono uno strumento potente. Siamo autorizzati a mettere in pausa il nostro gestore di azioni, cambiare stato più volte e gestire la logica asincrona.

Divertimento con i generatori

Quando mi è stato presentato per la prima volta Redux-Saga, ho pensato che fosse un modo troppo complicato per gestire le operazioni asincrone. In effetti, è un'implementazione piuttosto intelligente del modello di progettazione dei comandi. Il principale vantaggio di questo modello è che separa l'invocazione della logica e la sua effettiva implementazione.

In altre parole, diciamo quello che vogliamo ma non come dovrebbe accadere. La serie di blog di Matt Hink mi ha aiutato a capire come vengono implementate le saghe e consiglio vivamente di leggerla. Ho portato le stesse idee in Stent e, ai fini di questo articolo, diremo che cedendo materiale, stiamo dando istruzioni su ciò che vogliamo senza farlo effettivamente. Una volta eseguita l'azione, riceviamo il controllo indietro.

Al momento, un paio di cose possono essere inviate (cedute):

  • un oggetto di stato (o una stringa) per modificare lo stato della macchina;
  • una chiamata dell'helper di call (accetta una funzione sincrona, che è una funzione che restituisce una promessa o un'altra funzione del generatore) — in pratica stiamo dicendo: "Esegui questo per me e, se è asincrono, aspetta. Una volta che hai finito, dammi il risultato.”;
  • una chiamata dell'helper wait (accetta una stringa che rappresenta un'altra azione); se utilizziamo questa funzione di utilità, mettiamo in pausa il gestore e aspettiamo che venga inviata un'altra azione.

Ecco una funzione che illustra le varianti:

 const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }

Come possiamo vedere, il codice sembra sincrono, ma in realtà non lo è. È solo Stent che fa la parte noiosa dell'attesa della promessa risolta o dell'iterazione su un altro generatore.

Come Stent sta risolvendo i miei problemi di Redux

Troppo codice boilerplate

L'architettura Redux (e Flux) si basa sulle azioni che circolano nel nostro sistema. Quando l'applicazione cresce, di solito finiamo per avere molte costanti e creatori di azioni. Queste due cose si trovano molto spesso in cartelle diverse e il monitoraggio dell'esecuzione del codice a volte richiede tempo. Inoltre, quando aggiungiamo una nuova funzionalità, dobbiamo sempre fare i conti con un'intera serie di azioni, il che significa definire più nomi di azioni e creatori di azioni.

In Stent, non abbiamo nomi di azioni e la libreria crea automaticamente i creatori di azioni per noi:

 const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } } }); machine.addTodo({ title: 'Fix that bug' });

Abbiamo il creatore dell'azione machine.addTodo definito direttamente come metodo della macchina. Questo approccio ha risolto anche un altro problema che ho dovuto affrontare: trovare il riduttore che risponde a un'azione particolare. Di solito, nei componenti React, vediamo nomi di creatori di azioni come addTodo ; nei riduttori, invece, si lavora con un tipo di azione che è costante. A volte devo passare al codice del creatore dell'azione solo per poter vedere il tipo esatto. Qui, non abbiamo alcun tipo.

Cambiamenti di stato imprevedibili

In generale, Redux fa un buon lavoro nel gestire lo stato in modo immutabile. Il problema non è in Redux stesso, ma in quanto lo sviluppatore può inviare qualsiasi azione in qualsiasi momento. Se diciamo che abbiamo un'azione che accende le luci, va bene sparare quell'azione due volte di seguito? In caso negativo, come dovremmo risolvere questo problema con Redux? Bene, probabilmente inseriremmo del codice nel riduttore che protegge la logica e che controlla se le luci sono già accese, forse una clausola if che controlla lo stato corrente. Ora la domanda è: non è questo oltre lo scopo del riduttore? Il riduttore dovrebbe sapere di tali casi limite?

Quello che mi manca in Redux è un modo per fermare l'invio di un'azione in base allo stato corrente dell'applicazione senza inquinare il riduttore con la logica condizionale. E non voglio nemmeno prendere questa decisione per il livello di visualizzazione, dove viene licenziato il creatore dell'azione. Con Stent, ciò avviene automaticamente perché la macchina non risponde ad azioni che non sono dichiarate nello stato corrente. Per esempio:

 const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();

Il fatto che la macchina accetti solo input specifici in un dato momento ci protegge da strani bug e rende le nostre applicazioni più prevedibili.

Stati, non transizioni

Redux, come Flux, ci fa pensare in termini di transizioni. Il modello mentale di sviluppo con Redux è praticamente guidato dalle azioni e da come queste azioni trasformano lo stato nei nostri riduttori. Non è male, ma ho scoperto che ha più senso pensare invece in termini di stati: in quali stati potrebbe trovarsi l'app e in che modo questi stati rappresentano i requisiti aziendali.

Conclusione

Il concetto di macchine a stati nella programmazione, specialmente nello sviluppo dell'interfaccia utente, mi ha aperto gli occhi. Ho iniziato a vedere macchine a stati ovunque e ho un certo desiderio di passare sempre a quel paradigma. Vedo sicuramente i vantaggi di avere stati e transizioni più rigorosamente definiti tra di loro. Sono sempre alla ricerca di modi per rendere le mie app semplici e leggibili. Credo che le macchine a stati siano un passo in questa direzione. Il concetto è semplice e allo stesso tempo potente. Ha il potenziale per eliminare molti bug.