Máquinas de estados finitos con React

Índice
  1. API de contexto de React
  2. estado x
  3. El enfoque
  4. Definiendo nuestro contexto
  5. Definiendo nuestra máquina
  6. Creando nuestro proveedor de contexto
  7. Creando consumidores de contexto
  8. Usar el contexto desde lo más profundo del árbol de componentes
  9. Creación de aplicaciones más grandes con diagramas de estados.
  10. Las máquinas de estado y las herramientas de gestión de estado no son mutuamente excluyentes
  11. En conclusión

A medida que las aplicaciones JavaScript en la web se han vuelto más complejas, también lo ha hecho la complejidad de lidiar con el estado en esas aplicaciones; el estado es la suma de todos los datos que una aplicación necesita para realizar su función. En los últimos años, ha habido una gran innovación en el ámbito de la gestión estatal a través de herramientas como Redux, MobX y Vuex. Sin embargo, algo que no ha recibido tanta atención es el diseño estatal.

¿Qué diablos quiero decir con diseño estatal?

Preparemos un poco la escena. En el pasado, al crear una aplicación que necesita algunos datos de un servicio backend y mostrárselos al usuario, diseñé mi estado para usar indicadores booleanos para varias cosas como isLoading,,, y isSuccessasí isErrorsucesivamente. Sin embargo, a medida que esta cantidad de indicadores booleanos crece, la cantidad de estados posibles que mi aplicación puede tener crecer exponencialmente, lo que aumenta significativamente la probabilidad de que un usuario encuentre un estado de error o no intencional.

Para resolver este problema, pasé los últimos meses explorando el uso de máquinas de estados finitos como una forma de diseño mejor el estado de mis aplicaciones.

Las máquinas de estados finitos son un modelo matemático de computación, desarrollado inicialmente a principios de la década de 1940, que se ha utilizado durante décadas para construir hardware y software para una amplia gama de tecnologías.

Una máquina de estados finitos se puede definir como cualquier máquina abstracta que exista exactamente en uno de un número finito de estados en un momento dado. Sin embargo, en términos más prácticos, una máquina de estados se caracteriza por una lista de estados en los que cada estado define un conjunto finito y determinista de estados a los que se puede realizar una transición mediante una acción determinada.

Debido a esta naturaleza finita y determinista, podemos utilizar diagramas de estados para visualizar nuestra aplicación, antes o después de haberla creada.

Por ejemplo, si quisiéramos visualizar un flujo de trabajo de autenticación, podríamos tener tres estados generales en los que podría estar nuestra aplicación para un usuario: iniciada sesión, cerrada sesión o cargando.

Las máquinas de estados, debido a su previsibilidad, son especialmente populares en aplicaciones donde la confiabilidad es crítica, como el software de aviación, la fabricación e incluso el sistema de lanzamiento espacial de la NASA. También han sido un pilar en la comunidad de desarrollo de juegos durante décadas.

En este artículo, abordaremos la creación de algo que utilizan la mayoría de las aplicaciones en la web: la autenticación. Usaremos el diagrama de estado anterior para guiarnos.

Sin embargo, antes de comenzar, familiarícenos con algunas de las bibliotecas y API que usaremos para crear esta aplicación.

API de contexto de React

React 16.3 introdujo una versión nueva y estable de Context API. Si ha trabajado mucho con React en el pasado, es posible que esté familiarizado con cómo se pasan los datos de padres a hijos a través de accesorios. Cuando tenga ciertos datos que son necesarios para una variedad de componentes, puede terminar haciendo lo que se conoce como perforación de puntal: pasar datos a través de Múltiples niveles del árbol de componentes para llevar los datos a un componente que los necesita.

El contexto ayuda a aliviar el dolor de la perforación de accesorios al proporcionar una forma de compartir datos entre componentes sin tener que pasar esos datos claramente a través del árbol de componentes, lo que lo hace perfecto para almacenar datos de autenticación.

Cuando creamos contexto, obtendremos un Providerpar Consumer. El proveedor actuará como el componente “inteligente” con estado que contiene la definición de nuestra máquina de estados y mantiene un registro del estado actual de nuestra aplicación.

estado x

xstate es una biblioteca de JavaScript para gráficos de estados y máquinas de estados finitos funcionales y sin estado; nos proporcionará una API agradable y limpia para gestionar definiciones y transiciones a través de nuestros estados.

Una biblioteca de máquinas de estados finitos sin estado puede sonar un poco extraña, pero esencialmente lo que significa es que xstate solo se preocupa por el estado y la transición que usted pasa, lo que significa que depende de su aplicación realizar un seguimiento de su propio estado actual.

xstate tiene muchas características que vale la pena mencionar y que no cubriremos mucho en este artículo (ya que solo comenzaremos a arañar la superficie de los gráficos de estados): máquinas jerárquicas, máquinas paralelas, estados históricos y guardias, solo por nombrar algunas. .

El enfoque

Entonces, ahora que hemos tenido una pequeña introducción tanto a Context como a xstate, hablemos sobre el enfoque que adoptaremos.

Comenzaremos a definir el contexto de nuestra aplicación y luego crearemos un App /componente con estado (nuestro proveedor) que contendrá nuestra máquina de estado de autenticación, junto con información sobre el usuario actual y un método para que el usuario cierre sesión.

Para preparar un poco el escenario, echamos un vistazo rápido a una demostración de CodePen de lo que crearemos.

Entonces, sin más preámbulos, ¡profundicemos en algo de código!

Definiendo nuestro contexto

Lo primero que debemos hacer es definir el contexto de nuestra aplicación y configurarlo con algunos valores predeterminados. Los valores predeterminados en contexto son útiles para permitirnos probar componentes de forma aislada, ya que los valores predeterminados solo se usan si no hay un proveedor coincidente.

Para nuestra aplicación, vamos a configurar algunos valores predeterminados: authStatecuál será el estado de autenticación del usuario actual, un objeto llamado userque tendrá datos sobre nuestro usuario si está autenticado, luego un logout()método que se puede llamar en cualquier lugar. en la aplicación si el usuario está autenticado.

const Auth = React.createContext({  authState: 'login',  logout: () = {},  user: {},});

Definiendo nuestra máquina

Cuando pensamos en cómo se comporta la autenticación en una aplicación, en su forma más sencilla, hay tres estados principales: cerrar sesión, iniciar sesión y cargar. Estos son los tres estados que diagramamos anteriormente.

Volviendo a mirar ese diagrama de estado, nuestra máquina consta de esos mismos tres estados: desconectado, conectado y cargando. También tenemos cuatro tipos diferentes de acciones que se pueden ejecutar: SUBMIT, SUCCESS, FAILy LOGOUT.

Podemos modelar ese comportamiento en código así:

const appMachine = Machine({  initial: 'loggedOut',  states: {    loggedOut: {      onEntry: ['error'],      on: {        SUBMIT: 'loading',      },    },    loading: {      on: {        SUCCESS: 'loggedIn',        FAIL: 'loggedOut',      },    },    loggedIn: {      onEntry: ['setUser'],      onExit: ['unsetUser'],      on: {        LOGOUT: 'loggedOut',      },    },  },});

Entonces, acabamos de expresar el diagrama anterior en el código, pero ¿estás listo para que te cuente un pequeño secreto? Ese diagrama se genera a partir de este código utilizando la biblioteca xviz de David Khourshid, que puede usarse para explorar visualmente el código real que alimenta sus máquinas de estado.

Si está interesado en profundizar en interfaces de usuario complejas utilizando máquinas de estados finitos, David Khourshid tiene un artículo relacionado aquí sobre que vale la pena consultar.

Esta puede ser una herramienta increíblemente poderosa al intentar depurar estados problemáticos en su aplicación.

Volviendo al código anterior, definimos el estado inicial de nuestras aplicaciones, al que llamamos loggedOutporque queremos mostrar la pantalla de inicio de sesión en una visita inicial.

Tenga en cuenta que en una aplicación típica, probablemente querrá comenzar desde el estado de carga y determinar si el usuario fue autenticado previamente… pero como estamos falsificando el proceso de inicio de sesión, comenzaremos desde el estado de cierre de sesión.

En el statesobjeto, definemos cada uno de nuestros estados junto con las acciones y transiciones correspondientes para cada uno de esos estados. Luego pasó todo eso como un objeto a la Machine()función, que se importa desde xstate.

Junto con nuestros estados loggedOuty loggedIn, hemos definido algunas acciones que queremos activar cuando nuestra aplicación entre o salga de esos estados. Veremos qué hacen esas acciones en un momento.

Esta es nuestra máquina de estados.

Para desglosar las cosas una vez más, veamos la loggedOut: { on: { SUBMIT: 'loading'} } línea. Esto significa que si nuestra aplicación está en el loggedOutestado y llamamos a nuestra función de transición con una acción de SUBMIT, nuestra aplicación siempre pasará del estado desconectado al estado de carga. Podemos hacer esa transición llamando appMachine.transition('loggedOut', 'SUBMIT').

A partir de ahí, el estado de carga hará que el usuario avance como usuario autenticado o lo enviará de regreso a la pantalla de inicio de sesión y mostrará un mensaje de error.

Creando nuestro proveedor de contexto

El proveedor de contexto será el componente que se ubica en el nivel superior de nuestra aplicación y alberga todos los datos relacionados con un usuario autenticado (o no autenticado).

Trabajando en el mismo archivo que la definición de nuestra máquina de estados, creemos un App /componente y configurémoslo con todo lo que necesitaremos. No se preocupe, cubriremos lo que hace cada método en un momento.

class App extends React.Component {  constructor(props) {    super(props);    this.state = {      authState: appMachine.initialState.value,      error: '',      logout: e = this.logout(e),      user: {},    };  }  transition(event) {    const nextAuthState = appMachine.transition(this.state.authState, event.type);    const nextState = nextAuthState.actions.reduce(      (state, action) = this.command(action, event) || state,      undefined,    );    this.setState({      authState: nextAuthState.value,      ...nextState,    });  }  command(action, event) {    switch (action) {      case 'setUser':        if (event.username) {          return { user: { name: event.username } };        }        break;      case 'unsetUser':        return {          user: {},        };      case 'error':        if (event.error) {          return {            error: event.error,          };        }        break;      default:        break;    }  }  logout(e) {    e.preventDefault();    this.transition({ type: 'LOGOUT' });  }  render() {    return (      Auth.Provider value={this.state}        div className="w5"          div className="mb2"{this.state.error}/div          {this.state.authState === 'loggedIn' ? (            Dashboard /          ) : (            Login transition={event = this.transition(event)} /          )}        /div      /Auth.Provider    );  }}

¡Vaya, eso fue mucho código! Dividámoslo en partes manejables observando cada método de esta clase individualmente.

En constructor(), configuramos el estado de nuestro componente en el estado inicial de nuestro appMachine, además de configurar nuestra logoutfunción en el estado, para que pueda pasarse a través del contexto de nuestra aplicación a cualquier consumidor que lo necesite.

En el transition()método, estamos haciendo algunas cosas importantes. Primero, pasamos el estado actual de nuestra aplicación y el tipo de evento o acción a xstate, para que podamos determinar nuestro próximo estado. Luego, en nextState, realizamos cualquier acción asociada con el siguiente estado (que será una de nuestras acciones onEntryo onExit) y las ejecutamos a través del command()método; luego tomamos todos los resultados y configuramos nuestro nuevo estado de aplicación.

En el command()método, tenemos una declaración de cambio que devuelve un objeto, según el tipo de acción, que usamos para pasar datos al estado de nuestra aplicación. De esta manera, una vez que un usuario se haya autenticado, podemos establecer detalles relevantes sobre ese usuario (nombre de usuario, correo electrónico, identificación, etc.) en nuestro contexto, poniéndolos a disposición de cualquiera de nuestros componentes de consumo.

Finalmente, en nuestro render()método, en realidad estamos definiendo nuestro proveedor de componentes y luego pasando todo nuestro estado actual a través de los valueaccesorios, lo que hace que el estado esté disponible para todos los componentes debajo de él en el árbol de componentes. Luego, dependiendo del estado de nuestra aplicación, representamos el panel o el formulario de inicio de sesión para el usuario.

En este caso, tenemos un árbol de componentes bastante plano debajo de nuestro proveedor ( Auth.Provider), pero recuerde que el contexto permite que ese valor esté disponible para cualquier componente debajo de nuestro proveedor en el árbol de componentes, independientemente de su profundidad. Entonces, por ejemplo, si tenemos un componente anidado tres o cuatro niveles hacia abajo y queremos mostrar el nombre de usuario actual, podemos simplemente sacarlo de contexto, en lugar de profundizar hasta ese componente.

Creando consumidores de contexto

Ahora, vamos a crear algunos componentes que consumen el contexto de nuestra aplicación. A partir de estos componentes, podemos hacer todo tipo de cosas.

Podemos comenzar a construir un componente de inicio de sesión para nuestra aplicación.

class Login extends Component {  constructor(props) {    super(props);    this.state = {      yourName: '',    }    this.handleInput = this.handleInput.bind(this);  }  handleInput(e) {    this.setState({      yourName: e.target.value,    });  }  login(e) {    e.preventDefault();    this.props.transition({ type: 'SUBMIT' });    setTimeout(() = {      if (this.state.yourName) {        return this.props.transition({          type: 'SUCCESS',          username: this.state.yourName,        }, () = {          this.setState({ username: '' });        });      }      return this.props.transition({        type: 'FAIL',        error: 'Uh oh, you must enter your name!',      });    }, 2000);  }  render() {    return (      Auth.Consumer        {({ authState }) = (          form onSubmit={e = this.login(e)}            label htmlFor="yourName"              spanYour name/span              input                               name="yourName"                type="text"                value={this.state.yourName}                onChange={this.handleInput}              /            /label            input              type="submit"              value={authState === 'loading' ? 'Logging in...' : 'Login' }              disabled={authState === 'loading' ? true : false}            /          /form        )}      /Auth.Consumer    );  }}

¡Oh mi! Esa fue otra gran porción de código, así que repasemos cada método nuevamente.

En constructor(), declaramos nuestro estado predeterminado y vinculamos el handleInput()método para que haga referencia thisinternacionalmente a lo adecuado.

En handleInput(), tomamos el valor de nuestro campo de formulario de nuestro render()método y establecemos ese valor en estado: esto se conoce como un formulario controlado.

El login()método es donde normalmente colocaría su lógica de autenticación. En el caso de esta aplicación, solo estamos simulando una demora setTimeout()y autenticando al usuario (si proporciona un nombre) o devolviendo un error si el campo quedó vacío. Ten en cuenta que la transition()función que llama es en realidad la que definimos en nuestro App /componente, que se ha transmitido a través de propiedades.

Finalmente, nuestro render()método muestra nuestro formulario de inicio de sesión, pero observe que el Login /componente también es un consumidor de contexto. Estamos usando el authStatecontexto para determinar si mostraremos o no nuestro botón de inicio de sesión en un estado de carga deshabilitado.

Usar el contexto desde lo más profundo del árbol de componentes

Ahora que hemos manejado la creación de nuestra máquina de estados y una forma para que los usuarios inicien sesión en nuestra aplicación, ahora podemos confiar en tener información sobre ese usuario dentro de cualquier componente anidado bajo nuestro Dashboard /componente, ya que solo se representará si el usuario inicia sesión.

Ahora, vamos a crear un componente sin estado que tome el nombre de usuario del usuario autenticado actual y muestre un mensaje de bienvenida. Como estamos pasando el logout()método a todos nuestros consumidores, también podemos darle al usuario la opción de cerrar sesión desde cualquier parte del árbol de componentes.

const Dashboard = () = (  Auth.Consumer    {({ user, logout }) = (      div        divHello {user.name}/div        button onClick={e = logout(e)}          Logout        /button      /div    )}  /Auth.Consumer);

Creación de aplicaciones más grandes con diagramas de estados.

El uso de máquinas de estados finitos con React no tiene por qué limitarse a la autenticación ni a la API de contexto.

Al utilizar gráficos de estado, puede tener máquinas jerárquicas y/o máquinas paralelas, lo que significa que los componentes individuales de React pueden tener su propia máquina de estado interna, pero aún así estar conectados al estado general de su aplicación.

En este artículo, nos hemos centrado principalmente en el uso de xstate directamente con el API Context nativo; En aplicaciones más grandes, recomiendo encarecidamente mirar los reaccionar-autómatas, que proporcionan una fina capa de abstracción sobre xstate. reaccionar-automata tiene el beneficio adicional de poder generar pruebas automáticamente solo para sus componentes.

Las máquinas de estado y las herramientas de gestión de estado no son mutuamente excluyentes

Es fácil confundirse al pensar qué debes usar, digamos, xstate o Redux; pero es importante tener en cuenta que las máquinas de estados son más un concepto de implementación, preocupado por cómo diseña su estado, no necesariamente cómo lo administra.

De hecho, las máquinas de estado se pueden utilizar con casi cualquier herramienta de gestión de estado sin opiniones. Le animo a explorar varios enfoques para determinar cuál funciona mejor para usted, su equipo y sus aplicaciones.

En conclusión

Estos conceptos se pueden extender al mundo real sin tener que refactorizar toda la aplicación. Las máquinas de estados son un excelente objetivo de refactorización; es decir, la próxima vez que trabaje en un componente que esté lleno de indicadores booleanos como isFetchingy isError, considere refactorizar ese componente para usar una máquina de estados.

Como desarrollador front-end, descubrió que a menudo tengo que solucionar una de dos categorías de errores: problemas relacionados con la pantalla o estados inesperados de la aplicación.

Las máquinas de estados hacen que la segunda categoría prácticamente desaparezca.

Si estás interesado en profundizar en las máquinas de estados unidos, he pasado los últimos meses trabajando en un curso sobre máquinas de estados unidos finitos: si te registras en la lista de correo electrónico, recibirás un código de descuento cuando se lance el curso en agosto.

SUSCRÍBETE A NUESTRO BOLETÍN 
No te pierdas de nuestro contenido ni de ninguna de nuestras guías para que puedas avanzar en los juegos que más te gustan.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Subir

Este sitio web utiliza cookies para mejorar tu experiencia mientras navegas por él. Este sitio web utiliza cookies para mejorar tu experiencia de usuario. Al continuar navegando, aceptas su uso. Mas informacion