Mașini cu stări finite cu @wordpress/data și TypeScript
Publicat: 2021-09-16Să presupunem că vrem să proiectăm un ecran de conectare în aplicația noastră. Când utilizatorul îl vede pentru prima dată, există un formular gol. Utilizatorul completează câmpurile și, odată ce sunt toate setate, apasă butonul de autentificare pentru a valida acreditările și, ei bine, se autentifică. Dacă validarea reușește, trece la următorul ecran. Dar dacă nu, li se prezintă un mesaj de eroare și li se cere să încerce din nou.
Dacă ar fi să implementați starea unui astfel de ecran și ați citi postările mele despre cum să definiți starea unei aplicații cu TypeScript și @wordpress/data , pun pariu că ați face ceva de genul acesta:
type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };care are toate domeniile necesare pentru a gestiona statul, dar... este cel mai bun lucru pe care îl putem face?
Mașini cu stări finite
O mașină cu stări finite (FSM) este, în linii mari, un model matematic care ne permite să definim un set finit de stări și posibilele tranziții între ele. (mai multe informații pe Wikipedia). Îmi place această abstractizare pentru că se potrivește perfect nevoilor noastre când vine vorba de modelarea stării aplicațiilor noastre. De exemplu, diagrama de stare pentru ecranul nostru de conectare ar putea arăta cam așa:

care, după cum puteți vedea, surprinde într-un mod clar și concis comportamentul pe care vrem să îl implementăm.
Definirea statelor cu @wordpress/data
În seria noastră introductivă la React, am văzut cum să folosim pachetul @wordpress/data pentru a crea și gestiona starea aplicației noastre. Am făcut acest lucru prin definirea componentelor principale ale depozitului nostru de date:
- Un set de selectori pentru a interoga starea
- Un set de acțiuni pentru a declanșa o solicitare de actualizare
- O funcție de reducere pentru a actualiza starea, având în vedere starea curentă și o acțiune de actualizare
În esență, magazinele din @wordpress/data se comportă deja ca mașini cu stări finite, deoarece funcția de reducere „tranziție” de la o stare la alta folosind o acțiune:
( state: State, action: Action ) => State Cum se definește o mașină cu stări finite cu TypeScript în @wordpress/data
Se pare că @wordpress/data sunt destul de aproape de modelul FSM pe care l-am introdus câteva rânduri mai sus: ne lipsesc doar stările specifice în care ne putem afla și tranzițiile dintre ele... Deci, să aruncăm o privire mai atentă la cum putem remediați aceste probleme, pas câte un pas.
State explicite
Am început această postare arătând o posibilă implementare a stării aplicației noastre. Prima noastră propunere a fost o grămadă de atribute care au făcut treaba, dar nu arăta deloc ca o mașină cu stări finite. Deci, să începem prin a ne asigura că stările noastre sunt definite în mod explicit în modelul nostru:
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; }; Destul de evident, nu? Există un tip pentru fiecare stat, iar starea generală a aplicației este definită ca o uniune discriminată de tipuri. Se numește așa pentru că (a) statul este „uniunea diferitelor tipuri” (adică este fie Form , fie LoggingIn , sau este orice ) și (b) avem un atribut (în acest caz , status ) care ne permite să discriminăm ce stare specifică avem în fiecare moment.
Cred că această soluție este mult mai bună decât cea pe care o aveam la început. În soluția noastră inițială, am putut defini „stări invalide”, deoarece s-ar putea să se conecteze (doar setați isLoggingIn la true ) și, în același timp, să fie într-o stare de eroare (doar setați errorMessage la o altă valoare decât cea goală). şir). Dar acest lucru clar nu are sens! Care dintre ele este? Ne conectăm sau ar trebui să arătăm o eroare?
Noua soluție, pe de altă parte, este mult mai precisă când vine vorba de reprezentarea statului: face „imposibile” stările „invalide”. Dacă suntem în LoggingIn , nu există nicio modalitate de a configura un mesaj de eroare (nu există niciun atribut pentru acesta!). Dacă afișăm o Error , nu vă puteți conecta. Mult mai bine, nu crezi?
Acțiuni
În @wordpress/data , acțiunile actualizează magazinul din statul nostru. Deoarece ne modelăm starea ca FSM, acțiunile noastre vor fi săgețile din diagrama noastră originală. Tot ce trebuie să faceți este să le modelați ca o uniune discriminată de tipuri (prin convenție, discriminatorul este de obicei numit type ) și sunteți gata:

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'; };Acțiunile noastre ar trebui să reprezinte săgețile pe care le aveam în diagrama noastră originală, nu? Ei bine, nu știu despre tine, dar cred că aceste tipuri de acțiuni nu arată deloc ca săgeți! Săgețile au o direcție (trec de la o stare la alta); acțiunile nu.
Avem nevoie de o modalitate de a clarifica în codul nostru că anumite acțiuni sunt utile doar pentru a trece de la o stare la alta. Și până acum cea mai bună soluție pe care am găsit-o este să folosesc reductorul în sine:
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; } Dacă reductorul nostru începe prin a filtra status state , știm „sursa” săgeții noastre. Apoi, ne uităm la acțiunea curentă și, dacă este o „săgeată spre exterior”, generăm noua stare „țintă”.
Puteți vedea clar cum funcționează acest lucru în fragmentul de mai sus. Dacă status curentă este form , acțiunea SET_CREDENTIALS ne duce în aceeași stare în care ne aflam (actualizarea acreditărilor, duh) și acțiunea LOGIN schimbă starea în logging-in . Orice altă acțiune în această stare este ignorată și, prin urmare, starea este neschimbată.
Totul este bun și perfect, dar... Nu știu, nu-mi place atât de mult codul, nu? Vreau să mă asigur că codul meu este clar, concis, explicit de la sine și sigur de tip. putem face asta?
Reductor puternic tipizat
Pentru a remedia mizeria în care tocmai am intrat, trebuie doar să refactorăm reducer nostru, astfel încât fiecare stare posibilă din FSM-ul nostru să aibă propriul reductor. Dacă facem acest lucru corect, acestea vor fi tastate puternic și va fi clar ce este permis și ce nu.
Să începem prin a crea un tip pentru fiecare stare din aplicația noastră. Fiecare tip va grupa acțiunile care ne pot duce din starea dată în altă parte. De exemplu, starea noastră Form are două săgeți spre exterior ( SetCredentials și Login ), așa că haideți să creăm tipul FormAction cu unirea celor două acțiuni. Repetați procesul pentru fiecare stare și veți obține asta:
type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;Apoi, definiți toate reductoarele de care avem nevoie. Din nou, câte unul pentru fiecare stare:
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 ) => ...Datorită TypeScript și tipurilor pe care tocmai le-am definit, acum putem fi extrem de precis cu privire la starea sursă și la setul de acțiuni pe care fiecare reducător se așteaptă, precum și la starea (starile) țintă pe care le putem obține ca rezultat.
În cele din urmă, trebuie pur și simplu să legăm totul cu o funcție unică de reducer :
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; } }Codul rezultat este, în opinia mea, mai clar și mai ușor de întreținut. Și, pe deasupra, TypeScript este acum capabil să verifice ceea ce facem și să prindă orice scenarii lipsă. Minunat!
Concluzie
În această postare am văzut ce este o mașină cu stări finite și cum putem implementa una într-un magazin @wordpress/data cu TypeScript. Soluția noastră finală este destul de simplă și, totuși, oferă o mulțime de avantaje: surprinde mai bine starea și intenția aplicației noastre și valorifică puterea TypeScript.
Sper că v-a plăcut această postare! Dacă ați făcut-o, vă rugăm să-l împărtășiți prietenilor și colegilor. Și te rog să-mi spui cum abordezi aceste probleme în secțiunea de comentarii de mai jos.
Imagine prezentată de Patrick Hendry pe Unsplash.
