Máquina de estado finito con @wordpress/data y TypeScript

Publicada en WordPress.

Mira nuestro vídeo

Existe una versión mejor de tu web

Comparación de dos variantes de la misma página mediante test A/B

Comparte este artículo

Imagina que queremos diseñar un formulario de inicio de sesión en nuestra aplicación. Cuando el usuario o usuaria entra por primera vez, se le muestra el formulario vacío, esperando que lo rellene. Cuando todos los campos están bien, pulsa el botón de iniciar sesión para que validemos las credenciales. El resultado de esta validación pueden ser dos cosas: o bien se ha iniciado la sesión correctamente, con lo cual podemos empezar a usar la app, o bien las credenciales estaban mal y hay que mostrar un error.

Si viste mis entradas sobre cómo definir el estado de una aplicación con TypeScript y @wordpress/data, quizás hubieras implementado un estado parecido al siguiente:

type State = {
  readonly username: string;
  readonly password: string;
  readonly isLoggingIn: boolean;
  readonly errorMessage: string;
};

el cual tiene todos los campos necesarios para almacenar los credenciales de la usuaria/o y el estado en el que mi aplicación está. Pero… ¿podemos hacerlo mejor?

Máquina de estado finito

Una máquina de estado finito es, a grandes rasgos, un modelo computacional que nos permite definir un conjunto de estados finito y posibles transiciones entre ellos (tienes más información en Wikipedia). Esta abstracción es especialmente interesante porque nos permite modelar a la perfección el estado de nuestras aplicaciones. Por ejemplo, el diagrama de estados de nuestro formulario de inicio de sesión podría ser algo como esto:

Diagrama de estados de un formulario de inicio de sesión
Diagrama de estados de un formulario de inicio de sesión.

lo cual, como puedes apreciar, captura de una forma clara y concisa el funcionamiento que queremos implementar. Así, de un vistazo, es muy fácil entender dónde empezamos y cómo podemos pasar de un estado a otro.

El paquete @wordpress/data para gestionar el estado de nuestra app

En nuestra serie introductoria a React vimos cómo usar el paquete @wordpress/data para crear el estado de nuestra aplicación, el cual queda compuesto por tres piezas principales:

  • Un conjunto de selectores que nos permiten consultar el estado
  • Un conjunto de acciones que nos permiten lanzar una petición de actualización
  • Una función reductora tal que, dado el estado actual y una acción de actualización, devuelve el nuevo estado modificado

Así pues, la definición de un estado en @wordpress/data ya es, en esencia, una máquina de estado finito:

( state: State, action: Action ) => State

ya que tenemos «estados» y «transiciones» para pasar de uno a otro. Pero no es perfecto: ahora mismo no está claro qué estados concretos tenemos o qué transiciones son válidas entre ellos.

Cómo definir una máquina de estado finito con TypeScript en @wordpress/data

Los stores de @wordpress/data se acercan bastante a lo que queremos conseguir. No obstante, depende de nosotros hacer explícitos los estados en los que nuestra app puede estar, así como organizar el código de tal forma que TypeScript valide las transiciones entre ellos.

Definiendo los estados explícitamente

Como ya sabes, en un store de @wordpress/data tenemos que definir cómo será el estado que almacenaremos en él. Al principio de esta entrada, habíamos visto una propuesta en la que el estado eran un montón de atributos sueltos sin demasiada relación entre ellos. Pero ahora lo queremos modelar como una máquina de estado finito, así que deberemos explicitar todos los estados de nuestro esquema e indicar que el State puede ser cualquiera de ellos:

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

Esta forma de representar el estado se conoce como unión discriminada de tipos y, en mi opinión, es una solución muchísimo mejor de la que partíamos. Se llama así porque (a) el estado es «la unión de diferentes tipos» (esto es, o bien es Form, o bien es LoggingIn, o bien es lo-que-sea) y (b) disponemos de un atributo (en este caso, status) que nos permite discriminar qué estado concreto tenemos en cada momento. ¿Y por qué es mejor? Te lo explico brevemente:

En el estado que habíamos definido originalmente era posible, por ejemplo, indicar que estábamos validando las credenciales (poniendo a true el atributo isLoggingIn) y, a la vez, establecer un mensaje de error (escribiéndolo en errorMessage). Pero esto no tiene sentido, porque habíamos dicho que o bien estábamos iniciando la sesión o bien eso había fallado y mostrábamos un mensaje de error, ¿no?

La nueva solución, en cambio, es mucho más precisa a la hora de representar el estado. Con ella, hacemos «imposible» aquello que es «inválido». Si estamos en LoggingIn no tengo forma de definir un mensaje de error, porque no hay un atributo para ello. Y si estamos en Error no es posible que la UI piense que estamos iniciando sesión… ¡porque no lo estamos!

Acciones

Para poder cambiar el estado de nuestro store necesitamos definir un conjunto de acciones. En este caso, las acciones que necesitamos son, ni más ni menos, que las «flechas» del diagrama original:

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';
};

Las acciones vuelven a ser una unión discriminada de tipos (en este caso, el discriminador es type) que servirán para actualizar nuestro estado usando un reducer. Todo esto ya te lo conté con detalle en esta otra entrada. Por desgracia, esta solución tiene, de momento, un grave problema…

Se supone que nuestras acciones representan las «flechas» del diagrama original. No obstante, aquí están definidas como tipos sueltos, sin ton ni son. Las «flechas» tenían un origen y un destino; estas acciones, no. ¿Cómo arreglamos esto? Pues la mejor solución que he encontrado por ahora es dejar que la direccionalidad quede implícita en la función reductora:

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

filtrando primero por el status del estado actual (el origen de la flecha) y según cuál sea, viendo qué acción hemos recibido en el reducer (la flecha en sí) para generar el estado de salida correspondiente (el destino de la flecha). Si intentamos ejecutar una acción cuando no toca, este esquema simplemente la ignoraría y el estado no cambiaría.

Así, por ejemplo, puedes ver que cuando el estado de nuestra aplicación es form, la acción SET_CREDENTIALS nos mantiene en el mismo estado y simplemente actualiza los credenciales y la acción LOGIN cambia el estado a logging-in.

Por desgracia, el código resultante es muy confuso. Y, encima, TypeScript nos ayuda menos de lo que nos gustaría.

Función reductora fuertemente tipada

Para arreglar el follón que hemos montado en el anterior reducer, yo propongo refactorizar el código de tal forma que cada estado de origen esté gestionado por su propia función reductora. Veamos cómo.

Lo primero que haremos es crear un tipo para cada estado de nuestra aplicación que represente todas las acciones que pueden generarse en dicho estado. Por ejemplo, si en el estado Form podemos lanzar las acciones SetCredentials y Login, pues creamos el tipo FormAction con la unión de las dos acciones. Aquí ves el resultado completo:

type FormAction = SetCredentials | Login;
type LoggingInAction = ShowError | ShowApp;
type SuccessAction = ...;
type ErrorAction = BackToLoginForm;

type Action =
  | FormAction
  | LoggingInAction
  | SuccessAction
  | ErrorAction;

A continuación, definimos todas las funciones reductoras que necesitamos (una 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 ) => ...

y, finalmente, definimos la función reducer que lo ata todo:

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

Como puedes ver, cada reducer que hemos definido indica en su cabecera el estado del que partimos, las acciones que admite y los estados a los que podemos llegar. Gracias a explicitar de forma más clara nuestras expectativas, conseguimos que el código resultante se parezca más al diagrama original del que partíamos y, además, que TypeScript verifique que nuestro código es correcto.

Conclusión

En esta entrada hemos visto qué es una máquina de estado finito y cómo podemos implementar una en un store de @wordpress/data con TypeScript. La solución final a la que hemos llegado no es especialmente más complicada que la que hubiéramos conseguido de normal, pero a cambio hemos conseguido diseñar un sistema más preciso y seguro.

Espero que te haya gustado la entrada y, si es así, compártela con tus colegas. ¡Ah! Y si sabes cómo hacerlo aún mejor, explícamelo en los comentarios porque quiero seguir aprendiendo 🙂

Imagen destacada de Patrick Hendry en Unsplash.

Deja una respuesta

No publicaremos tu correo electrónico. Los campos obligatorios están marcados con: •

He leído y acepto la Política de privacidad de Nelio Software

Al marcar la casilla de aceptación estás dando tu legítimo consentimiento para que tu información personal se almacene en SiteGround y sea usada por Nelio Software con el propósito único de publicar aquí este comentario. Contáctanos para corregir, limitar, eliminar o acceder a tu información.