Máquinas de estados finitos con @wordpress/data y TypeScript
Publicado: 2021-09-16Supongamos que queremos diseñar una pantalla de inicio de sesión en nuestra aplicación. Cuando el usuario lo ve por primera vez, hay un formulario vacío. El usuario completa los campos y, una vez que están todos configurados, presiona el botón de inicio de sesión para validar las credenciales y, bueno, iniciar sesión. Si la validación tiene éxito, pasa a la siguiente pantalla. Pero si no es así, se les presenta un mensaje de error y se les pide que lo intenten de nuevo.
Si implementara el estado de dicha pantalla y leyera mis publicaciones sobre cómo definir el estado de una aplicación con TypeScript y @wordpress/data
, apuesto a que haría algo como esto:
type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };
que tiene todos los campos necesarios para administrar el estado pero… ¿es esto lo mejor que podemos hacer?
Máquinas de estados finitos
Una máquina de estados finitos (FSM) es, en términos generales, un modelo matemático que nos permite definir un conjunto finito de estados y las posibles transiciones entre ellos. (más información en Wikipedia). Me gusta esta abstracción porque se ajusta perfectamente a nuestras necesidades a la hora de modelar el estado de nuestras aplicaciones. Por ejemplo, el diagrama de estado de nuestra pantalla de inicio de sesión podría verse así:

el cual, como puedes ver, plasma de manera clara y concisa el comportamiento que queremos implementar.
Definición de estados con @wordpress/data
En nuestra serie de introducción a React, vimos cómo usar el paquete @wordpress/data
para crear y administrar el estado de nuestra aplicación. Lo hicimos definiendo los componentes principales de nuestro almacén de datos:
- Un conjunto de selectores para consultar el estado.
- Un conjunto de acciones para desencadenar una solicitud de actualización
- Una función reductora para, dado el estado actual y una acción de actualización, actualizar el estado
En esencia, las tiendas en @wordpress/data
ya se comportan como máquinas de estados finitos, ya que la función reductora “transiciona” de un estado a otro usando una acción:
( state: State, action: Action ) => State
Cómo definir una máquina de estados finitos con TypeScript en @wordpress/data
Parece que los almacenes @wordpress/data
están bastante cerca del modelo FSM que presentamos unas líneas más arriba: solo nos faltan los estados específicos en los que podemos estar y las transiciones entre ellos... Así que echemos un vistazo más de cerca a cómo podemos solucionar estos problemas, un paso a la vez.
Estados explícitos
Comenzamos esta publicación mostrando una posible implementación del estado de nuestra aplicación. Nuestra primera propuesta era un conjunto de atributos que hacían el trabajo, pero no parecía en absoluto una máquina de estados finitos. Entonces, comencemos asegurándonos de que nuestros estados estén definidos explícitamente en nuestro modelo:
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; };
Bastante obvio, ¿eh? Hay un tipo por estado y el estado general de la aplicación se define como una unión discriminada de tipos. Se llama así porque (a) el estado es “la unión de diferentes tipos” (es decir, es Form
, o es LoggingIn
, o es lo que sea ) y (b) tenemos un atributo (en este caso , status
) que nos permite discriminar qué estado concreto tenemos en cada momento.
Creo que esta solución es mucho mejor que la que teníamos al principio. En nuestra solución original, pudimos definir "estados no válidos", porque uno podría estar iniciando sesión (simplemente establezca isLoggingIn
en true
) y, al mismo tiempo, esté en un estado de error (simplemente establezca errorMessage
en un valor que no sea el valor vacío). cuerda). ¡Pero esto claramente no tiene sentido! ¿Cuál es? ¿Estamos iniciando sesión o se supone que debemos mostrar un error?
La nueva solución, en cambio, es mucho más precisa a la hora de representar el estado: hace que los estados “inválidos” sean “imposibles”. Si estamos en LoggingIn
, no hay forma de configurar un mensaje de error (¡no hay ningún atributo para ello!). Si mostramos un Error
, no puedes iniciar sesión. Mucho mejor, ¿no crees?
Comportamiento
En @wordpress/data
, las acciones actualizan la tienda en nuestro estado. Dado que estamos modelando nuestro estado como un FSM, nuestras acciones serán las flechas de nuestro diagrama original. Todo lo que tiene que hacer es modelarlos como una unión discriminada de tipos (por convención, el discriminador generalmente se llama type
) y está listo para comenzar:

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'; };
Se supone que nuestras acciones representan las flechas que teníamos en nuestro diagrama original, ¿verdad? Bueno, no sé ustedes, ¡pero creo que estos tipos de acción no parecen flechas en absoluto! Las flechas tienen una dirección (van de un estado a otro); las acciones no.
Necesitamos una forma de dejar claro en nuestro código que ciertas acciones solo son útiles para pasar de un estado a otro. Y hasta ahora, la mejor solución que he encontrado es usar el propio reductor:
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; }
Si nuestro reductor comienza filtrando el status
de nuestro state
, conocemos la "fuente" de nuestra flecha. Luego, observamos la acción actual y, si es una "flecha hacia afuera", generamos el nuevo estado "objetivo".
Puede ver claramente cómo funciona esto en el fragmento anterior. Si el status
actual es form
, la acción SET_CREDENTIALS
nos lleva al mismo estado en el que estábamos (actualizando las credenciales, duh) y la acción LOGIN
cambia el estado a logging-in
. Cualquier otra acción en este estado se ignora y, por lo tanto, el estado no cambia.
Eso está todo bien y perfecto pero… no sé, no me gusta mucho el código, ¿a ti? Quiero asegurarme de que mi código sea claro, conciso, autoexplicativo y seguro. ¿Podemos hacer eso?
Reductor fuertemente tipado
Para arreglar el lío en el que acabamos de meternos, solo necesitamos refactorizar nuestro reducer
para que cada estado posible en nuestro FSM tenga su propio reductor. Si hacemos esto correctamente, se tipificarán fuertemente y quedará claro qué está permitido y qué no.
Comencemos por crear un tipo para cada estado en nuestra aplicación. Cada tipo agrupará las acciones que nos pueden llevar de un estado dado a otro. Por ejemplo, nuestro estado de Form
tiene dos flechas hacia afuera ( SetCredentials
e Login
), así que vamos a crear el tipo FormAction
con la unión de las dos acciones. Repite el proceso para cada estado y obtendrás esto:
type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;
A continuación, defina todos los reductores que necesitamos. Nuevamente, uno para cada estado:
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 ) => ...
Gracias a TypeScript y los tipos que acabamos de definir, ahora podemos ser extremadamente precisos sobre el estado de origen y el conjunto de acciones que espera cada reductor, así como los estados de destino que podemos obtener como resultado.
Finalmente, simplemente necesitamos vincularlo todo con una función reducer
única:
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; } }
El código resultante es, en mi opinión, más claro y fácil de mantener. Y, además de eso, TypeScript ahora puede verificar lo que estamos haciendo y detectar cualquier escenario que falte. ¡Increíble!
Conclusión
En esta publicación hemos visto qué es una máquina de estados finitos y cómo podemos implementar una en un @wordpress/data
store con TypeScript. Nuestra solución final es bastante simple y, sin embargo, ofrece muchas ventajas: captura mejor el estado y la intención de nuestra aplicación y aprovecha el poder de TypeScript.
¡Espero que les haya gustado esta publicación! Si lo hizo, por favor compártalo con sus amigos y colegas. Y hágame saber cómo aborda estos problemas en la sección de comentarios a continuación.
Imagen destacada de Patrick Hendry en Unsplash.