Macchine a stati finiti con @wordpress/data e TypeScript
Pubblicato: 2021-09-16Supponiamo di voler progettare una schermata di accesso nella nostra applicazione. Quando l'utente lo vede per la prima volta, c'è un modulo vuoto. L'utente compila i campi e, una volta impostati tutti, preme il pulsante login per convalidare le credenziali e, bene, effettuare il login. Se la convalida ha esito positivo, passa alla schermata successiva. Ma in caso contrario, viene presentato un messaggio di errore e viene richiesto di riprovare.
Se dovessi implementare lo stato di uno schermo del genere e leggi i miei post su come definire lo stato di un'applicazione con TypeScript e @wordpress/data
, scommetto che faresti qualcosa del genere:
type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };
che ha tutti i campi necessari per gestire lo stato ma… è questo il meglio che possiamo fare?
Macchine a stati finiti
Una macchina a stati finiti (FSM) è, in generale, un modello matematico che permette di definire un insieme finito di stati e le possibili transizioni tra di essi. (maggiori informazioni su Wikipedia). Mi piace questa astrazione perché si adatta perfettamente alle nostre esigenze quando si tratta di modellare lo stato delle nostre applicazioni. Ad esempio, il diagramma di stato per la nostra schermata di accesso potrebbe essere simile a questo:

che, come puoi vedere, coglie in modo chiaro e conciso il comportamento che vogliamo mettere in atto.
Definizione degli stati con @wordpress/data
Nella nostra serie introduttiva a React, abbiamo visto come utilizzare il pacchetto @wordpress/data
per creare e gestire lo stato della nostra app. Lo abbiamo fatto definendo i componenti principali del nostro datastore:
- Un insieme di selettori per interrogare lo stato
- Un insieme di azioni per attivare una richiesta di aggiornamento
- Una funzione di riduzione per aggiornare lo stato, dato lo stato corrente e un'azione di aggiornamento
In sostanza, i negozi in @wordpress/data
si comportano già come macchine a stati finiti, poiché la funzione di riduzione "transizioni" da uno stato all'altro usando un'azione:
( state: State, action: Action ) => State
Come definire una macchina a stati finiti con TypeScript in @wordpress/data
Sembra che @wordpress/data
store siano abbastanza simili al modello FSM che abbiamo introdotto poche righe sopra: ci mancano solo gli stati specifici in cui possiamo trovarci e le transizioni tra di loro... Quindi diamo un'occhiata più da vicino a come possiamo risolvere questi problemi, un passo alla volta.
Stati espliciti
Abbiamo iniziato questo post mostrando una possibile implementazione dello stato della nostra app. La nostra prima proposta era un insieme di attributi che portavano a termine il lavoro, ma non sembrava affatto una macchina a stati finiti. Quindi iniziamo assicurandoci che i nostri stati siano esplicitamente definiti nel nostro modello:
type State = | Form | LoggingIn | Success | Error; type Form = { readonly status: 'form'; readonly username: string; readonly password: string; }; type LoggingIn = { readonly status: 'logging-in'; readonly username: string; readonly password: string; }; type Success = { readonly status: 'success'; // ... }; type Error = { readonly status: 'error'; readonly message: string; };
Abbastanza ovvio, eh? Esiste un tipo per stato e lo stato generale dell'app è definito come un'unione discriminata di tipi. È così chiamato perché (a) lo stato è "l'unione di tipi diversi" (cioè è o Form
, o è LoggingIn
, o è qualunque cosa ) e (b) abbiamo un attributo (in questo caso , status
) che ci permette di discriminare quale stato specifico abbiamo in ogni momento.
Penso che questa soluzione sia molto migliore di quella che avevamo all'inizio. Nella nostra soluzione originale siamo stati in grado di definire "stati non validi", perché si potrebbe accedere (impostare semplicemente isLoggingIn
su true
) e, allo stesso tempo, essere in uno stato di errore (impostare semplicemente errorMessage
su un valore diverso da vuoto corda). Ma questo chiaramente non ha senso! Qual é? Stiamo effettuando il login o dovremmo mostrare un errore?
La nuova soluzione, invece, è molto più precisa quando si tratta di rappresentare lo Stato: rende “impossibili” gli stati “non validi”. Se siamo in LoggingIn
, non c'è modo di impostare un messaggio di errore (non c'è nessun attributo per esso!). Se stiamo mostrando un Error
, non puoi accedere. Molto meglio, non credi?
Azioni
In @wordpress/data
, le azioni aggiornano il negozio nel nostro stato. Dal momento che stiamo modellando il nostro stato come FSM, le nostre azioni saranno le frecce del nostro diagramma originale. Tutto quello che devi fare è modellarli come un'unione discriminata di tipi (per convenzione, il discriminatore è solitamente chiamato type
) e sei a posto:

type SetCredentials = { readonly type: 'SET_CREDENTIALS'; readonly username: string; readonly password: string; }; type Login = { readonly type: 'LOGIN'; }; type ShowApp = { readonly type: 'SHOW_APP'; // ... }; type ShowError = { readonly type: 'SHOW_ERROR'; readonly message: string; }; type BackToLoginForm = { readonly type: 'BACK_TO_LOGIN_FORM'; };
Le nostre azioni dovrebbero rappresentare le frecce che avevamo nel nostro diagramma originale, giusto? Bene, non so voi, ma penso che questi tipi di azioni non assomiglino affatto a frecce! Le frecce hanno una direzione (va da uno stato all'altro); le azioni no.
Abbiamo bisogno di un modo per chiarire nel nostro codice che determinate azioni sono utili solo per passare da uno stato all'altro. E finora la soluzione migliore che ho trovato è usare il riduttore stesso:
function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } case ...: ... } return state; }
Se il nostro riduttore inizia filtrando lo status
del nostro state
, conosciamo la "fonte" della nostra freccia. Quindi, esaminiamo l'azione corrente e, se è una "freccia verso l'esterno", generiamo il nuovo stato "bersaglio".
Puoi vedere chiaramente come funziona nello snippet sopra. Se lo status
corrente è form
, l'azione SET_CREDENTIALS
ci riporta allo stesso stato in cui ci trovavamo (aggiornando le credenziali, duh) e l'azione LOGIN
cambia lo stato in logging-in
. Qualsiasi altra azione in questo stato viene ignorata e, pertanto, lo stato rimane invariato.
Va tutto bene e perfetto ma... non lo so, il codice non mi piace molto, vero? Voglio assicurarmi che il mio codice sia chiaro, conciso, autoesplicativo e type-safe. Possiamo farlo?
Riduttore fortemente tipizzato
Per risolvere il pasticcio in cui siamo appena entrati, dobbiamo solo riorganizzare il nostro reducer
in modo che ogni possibile stato nel nostro FSM abbia il proprio riduttore. Se lo facciamo correttamente, verranno digitati in modo forte e sarà chiaro cosa è consentito e cosa no.
Iniziamo creando un tipo per ogni stato nella nostra applicazione. Ciascun tipo raggrupperà le azioni che possono portarci dallo stato specificato a un altro posto. Ad esempio, il nostro stato Form
ha due frecce verso l'esterno ( SetCredentials
e Login
), quindi creiamo il tipo FormAction
con l'unione delle due azioni. Ripeti il processo per ogni stato e otterrai questo:
type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;
Quindi, definiamo tutti i riduttori di cui abbiamo bisogno. Ancora una volta, uno per ogni stato:
function reduceForm( state: Form, action: FormAction ): Form | LoggingIn { switch ( action.type ) { switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } } reduceLoggingIn: ( state: LoggingIn, action: LoggingInAction ) => Success | Error reduceError: ( state: Error, action: ErrorAction ) => Form reduceSuccess: ( state: Success, action: SuccessAction ) => ...
Grazie a TypeScript e ai tipi che abbiamo appena definito, ora possiamo essere estremamente precisi sullo stato di origine e sull'insieme di azioni che ogni riduttore si aspetta, nonché sugli stati di destinazione che possiamo ottenere come risultato.
Infine, dobbiamo semplicemente legare il tutto con una funzione di reducer
unica:
function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': return reduceForm( state, action as FormActions ) ?? state; case 'logging-in': return reduceLoggingIn( state, action as LoggingInActions ) ?? state; case 'error': return reduceError( state, action as ErrorActions ) ?? state; case 'success': return reduceSuccess( state, action as SuccessActions ) ?? state; } }
Il codice risultante è, a mio parere, più chiaro e più facile da mantenere. Inoltre, TypeScript è ora in grado di verificare ciò che stiamo facendo e rilevare eventuali scenari mancanti. Stupendo!
Conclusione
In questo post abbiamo visto cos'è una macchina a stati finiti e come possiamo implementarne una in un @wordpress/data
store con TypeScript. La nostra soluzione finale è abbastanza semplice e, tuttavia, offre molti vantaggi: cattura meglio lo stato e l'intento della nostra app e sfrutta la potenza di TypeScript.
Spero che questo post ti sia piaciuto! Se l'hai fatto, condividilo con i tuoi amici e colleghi. E per favore fatemi sapere come affronti questi problemi nella sezione commenti qui sotto.
Immagine in primo piano di Patrick Hendry su Unsplash.