El auge de las máquinas estatales
Publicado: 2022-03-10Ya es 2018 e innumerables desarrolladores front-end todavía están liderando una batalla contra la complejidad y la inmovilidad. Mes tras mes, han buscado el santo grial: una arquitectura de aplicación libre de errores que les ayude a entregar rápidamente y con alta calidad. Soy uno de esos desarrolladores y encontré algo interesante que podría ayudar.
Hemos dado un buen paso adelante con herramientas como React y Redux. Sin embargo, no son suficientes por sí solos en aplicaciones a gran escala. Este artículo le presentará el concepto de máquinas de estado en el contexto del desarrollo front-end. Probablemente ya hayas construido varios de ellos sin darte cuenta.
Una introducción a las máquinas de estado
Una máquina de estado es un modelo matemático de computación. Es un concepto abstracto por el cual la máquina puede tener diferentes estados, pero en un momento dado cumple solo uno de ellos. Hay diferentes tipos de máquinas de estado. Creo que la más famosa es la máquina de Turing. Es una máquina de estados infinitos, lo que significa que puede tener innumerables estados. La máquina de Turing no encaja bien en el desarrollo de la interfaz de usuario actual porque, en la mayoría de los casos, tenemos un número finito de estados. Esta es la razón por la que las máquinas de estados finitos, como Mealy y Moore, tienen más sentido.
La diferencia entre ellos es que la máquina de Moore cambia su estado basándose únicamente en su estado anterior. Desafortunadamente, tenemos muchos factores externos, como las interacciones de los usuarios y los procesos de red, lo que significa que la máquina de Moore tampoco es lo suficientemente buena para nosotros. Lo que estamos buscando es la máquina Mealy. Tiene un estado inicial y luego pasa a nuevos estados según la entrada y su estado actual.
Una de las formas más fáciles de ilustrar cómo funciona una máquina de estado es observar un torniquete. Tiene un número finito de estados: bloqueado y desbloqueado. Aquí hay un gráfico simple que nos muestra estos estados, con sus posibles entradas y transiciones.
El estado inicial del torniquete está bloqueado. No importa cuántas veces lo presionemos, permanece en ese estado bloqueado. Sin embargo, si le pasamos una moneda, entonces pasa al estado desbloqueado. Otra moneda en este punto no haría nada; todavía estaría en el estado desbloqueado. Un empujón desde el otro lado funcionaría y podríamos pasar. Esta acción también hace que la máquina pase al estado bloqueado inicial.
Si quisiéramos implementar una sola función que controle el torniquete, probablemente terminaríamos con dos argumentos: el estado actual y una acción. Y si usa Redux, esto probablemente le suene familiar. Es similar a la conocida función de reducción, donde recibimos el estado actual y, en función de la carga útil de la acción, decidimos cuál será el siguiente estado. El reductor es la transición en el contexto de las máquinas de estado. De hecho, cualquier aplicación que tenga un estado que podamos cambiar de alguna manera puede llamarse máquina de estado. Es solo que estamos implementando todo manualmente una y otra vez.
¿Cómo es mejor una máquina de estado?
En el trabajo, usamos Redux y estoy muy contento con él. Sin embargo, he empezado a ver patrones que no me gustan. Por "no me gusta", no quiero decir que no funcionen. Es más que agregan complejidad y me obligan a escribir más código. Tuve que emprender un proyecto paralelo en el que tenía espacio para experimentar, y decidí repensar nuestras prácticas de desarrollo de React y Redux. Empecé a tomar notas sobre las cosas que me preocupaban y me di cuenta de que una abstracción de la máquina de estados realmente resolvería algunos de estos problemas. Entremos y veamos cómo implementar una máquina de estado en JavaScript.
Vamos a atacar un problema simple. Queremos obtener datos de una API de back-end y mostrárselos al usuario. El primer paso es aprender a pensar en estados, en lugar de transiciones. Antes de entrar en las máquinas de estado, mi flujo de trabajo para crear una función de este tipo solía verse así:
- Mostramos un botón de obtención de datos.
- El usuario hace clic en el botón de obtención de datos.
- Dispare la solicitud al back-end.
- Recuperar los datos y analizarlos.
- Muéstralo al usuario.
- O, si hay un error, muestre el mensaje de error y muestre el botón de obtención de datos para que podamos iniciar el proceso nuevamente.
Estamos pensando linealmente y básicamente tratando de cubrir todas las direcciones posibles hacia el resultado final. Un paso lleva a otro, y rápidamente comenzaríamos a ramificar nuestro código. ¿Qué pasa con problemas como que el usuario haga doble clic en el botón, o que el usuario haga clic en el botón mientras esperamos la respuesta del back-end, o que la solicitud tenga éxito pero los datos estén dañados? En estos casos, probablemente tendríamos varias banderas que nos muestran lo que pasó. Tener banderas significa más cláusulas if
y, en aplicaciones más complejas, más conflictos.
Esto se debe a que estamos pensando en transiciones. Nos estamos enfocando en cómo ocurren estas transiciones y en qué orden. Centrarse en cambio en los diversos estados de la aplicación sería mucho más simple. ¿Cuántos estados tenemos y cuáles son sus posibles entradas? Usando el mismo ejemplo:
- inactivo
En este estado, mostramos el botón de búsqueda de datos, nos sentamos y esperamos. La acción posible es:- hacer clic
Cuando el usuario hace clic en el botón, activamos la solicitud al back-end y luego hacemos la transición de la máquina a un estado de "recuperación".
- hacer clic
- atractivo
La solicitud está en vuelo, y nos sentamos y esperamos. Las acciones son:- éxito
Los datos llegan con éxito y no están dañados. Usamos los datos de alguna manera y volvemos al estado "inactivo". - falla
Si hay un error al realizar la solicitud o analizar los datos, pasamos a un estado de "error".
- éxito
- error
Mostramos un mensaje de error y mostramos el botón de obtención de datos. Este estado acepta una acción:- rever
Cuando el usuario hace clic en el botón de reintento, activamos la solicitud nuevamente y la máquina pasa al estado de "recuperación".
- rever
Hemos descrito aproximadamente los mismos procesos, pero con estados y entradas.
Esto simplifica la lógica y la hace más predecible. También resuelve algunos de los problemas mencionados anteriormente. Tenga en cuenta que, mientras estamos en estado de "recuperación", no aceptamos ningún clic. Entonces, incluso si el usuario hace clic en el botón, no pasará nada porque la máquina no está configurada para responder a esa acción mientras está en ese estado. Este enfoque elimina automáticamente la bifurcación impredecible de nuestra lógica de código. Esto significa que tendremos menos código para cubrir durante las pruebas . Además, algunos tipos de pruebas, como las pruebas de integración, se pueden automatizar. Piense en cómo tendríamos una idea realmente clara de lo que hace nuestra aplicación, y podríamos crear un script que repase los estados y transiciones definidos y que genere aserciones. Estas afirmaciones podrían probar que hemos llegado a todos los estados posibles o recorrido un viaje en particular.
De hecho, escribir todos los estados posibles es más fácil que escribir todas las transiciones posibles porque sabemos qué estados necesitamos o tenemos. Por cierto, en la mayoría de los casos, los estados describirían la lógica de negocio de nuestra aplicación, mientras que las transiciones suelen ser desconocidas al principio. Los errores en nuestro software son el resultado de acciones enviadas en un estado incorrecto y/o en el momento incorrecto. Dejan nuestra aplicación en un estado que no conocemos, y esto rompe nuestro programa o hace que se comporte de forma incorrecta. Por supuesto, no queremos estar en tal situación. Las máquinas de estado son buenos cortafuegos . Nos protegen de alcanzar estados desconocidos porque establecemos límites para lo que puede suceder y cuándo, sin decir explícitamente cómo. El concepto de una máquina de estado combina muy bien con un flujo de datos unidireccional. Juntos, reducen la complejidad del código y aclaran el misterio de dónde se originó un estado.
Creación de una máquina de estado en JavaScript
Basta de hablar, veamos un poco de código. Usaremos el mismo ejemplo. Con base en la lista anterior, comenzaremos con lo siguiente:
const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } } }
Tenemos los estados como objetos y sus posibles entradas como funciones. Sin embargo, falta el estado inicial. Cambiemos el código de arriba a esto:
const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } } }
Una vez que definimos todos los estados que tienen sentido para nosotros, estamos listos para enviar la entrada y cambiar de estado. Lo haremos usando los dos métodos de ayuda a continuación:
const machine = { dispatch(actionName, ...payload) { const actions = this.transitions[this.state]; const action = this.transitions[this.state][actionName]; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ... }
La función de dispatch
verifica si hay una acción con el nombre dado en las transiciones del estado actual. Si es así, lo dispara con la carga útil dada. También llamamos al controlador de action
con la machine
como contexto, para que podamos enviar otras acciones con this.dispatch(<action>)
o cambiar el estado con this.changeStateTo(<new state>)
.
Siguiendo el viaje del usuario de nuestro ejemplo, la primera acción que tenemos que enviar es click
. Así es como se ve el controlador de esa acción:
transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ... } machine.dispatch('click');
Primero cambiamos el estado de la máquina a ir a fetching
. Luego, activamos la solicitud al back-end. Supongamos que tenemos un servicio con un método getData
que devuelve una promesa. Una vez que se resuelve y el análisis de datos está bien, despachamos success
, si no failure
.
Hasta aquí todo bien. A continuación, tenemos que implementar acciones y entradas de success
y failure
en el estado de fetching
:
transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ... }
Fíjate cómo hemos liberado a nuestro cerebro de tener que pensar en el proceso anterior. No nos importan los clics de los usuarios ni lo que sucede con la solicitud HTTP. Sabemos que la aplicación está en un estado de fetching
y estamos esperando solo estas dos acciones. Es un poco como escribir nueva lógica de forma aislada.
El último bit es el estado de error
. Sería bueno si proporcionamos esa lógica de reintento para que la aplicación pueda recuperarse de la falla.
transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } } }
Aquí tenemos que duplicar la lógica que escribimos en el controlador de click
. Para evitar eso, debemos definir el controlador como una función accesible para ambas acciones, o primero hacemos la transición al estado idle
y luego despachamos la acción de click
manualmente.
Puede encontrar un ejemplo completo de la máquina de estado de trabajo en mi Codepen.
Gestión de máquinas de estado con una biblioteca
El patrón de máquina de estados finitos funciona independientemente de si usamos React, Vue o Angular. Como vimos en la sección anterior, podemos implementar fácilmente una máquina de estado sin muchos problemas. Sin embargo, a veces una biblioteca proporciona más flexibilidad. Algunos de los buenos son Machina.js y XState. En este artículo, sin embargo, hablaremos sobre Stent, mi biblioteca similar a Redux que se basa en el concepto de máquinas de estados finitos.
Stent es una implementación de un contenedor de máquinas de estado. Sigue algunas de las ideas de los proyectos Redux y Redux-Saga, pero proporciona, en mi opinión, procesos más simples y sin repeticiones. Se desarrolla utilizando el desarrollo basado en Léame, y literalmente pasé semanas solo en el diseño de la API. Debido a que estaba escribiendo la biblioteca, tuve la oportunidad de solucionar los problemas que encontré al usar las arquitecturas Redux y Flux.
Creando Máquinas
En la mayoría de los casos, nuestras aplicaciones cubren múltiples dominios. No podemos ir con una sola máquina. Entonces, Stent permite la creación de muchas máquinas:
import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });
Más tarde, podemos acceder a estas máquinas usando el método Machine.get
:
const machineA = Machine.get('A'); const machineB = Machine.get('B');
Conexión de las máquinas a la lógica de renderizado
En mi caso, el renderizado se realiza a través de React, pero podemos usar cualquier otra biblioteca. Se reduce a disparar una devolución de llamada en la que activamos la representación. Una de las primeras funciones en las que trabajé fue la función de connect
:
import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });
Decimos qué máquinas son importantes para nosotros y damos sus nombres. La devolución de llamada que pasamos al map
se activa una vez inicialmente y luego cada vez que cambia el estado de algunas de las máquinas. Aquí es donde activamos el renderizado. En este punto, tenemos acceso directo a las máquinas conectadas, por lo que podemos recuperar el estado y los métodos actuales. También hay mapOnce
, para activar la devolución de llamada solo una vez, y mapSilent
, para omitir esa ejecución inicial.
Para mayor comodidad, se exporta un ayudante específicamente para la integración de React. Es muy similar al connect(mapStateToProps)
de Redux.
import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });
Stent ejecuta nuestra devolución de llamada de mapeo y espera recibir un objeto, un objeto que se envía como props
a nuestro componente React.
¿Qué es el estado en el contexto del stent?
Hasta ahora, nuestro estado ha sido simples cadenas. Desafortunadamente, en el mundo real, tenemos que mantener más de una cadena en estado. Es por eso que el estado de Stent es en realidad un objeto con propiedades en su interior. La única propiedad reservada es name
. Todo lo demás son datos específicos de la aplicación. Por ejemplo:
{ name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }
Mi experiencia con Stent hasta ahora me muestra que si el objeto de estado se vuelve más grande, probablemente necesitemos otra máquina que maneje esas propiedades adicionales. Identificar los diversos estados lleva algún tiempo, pero creo que este es un gran paso adelante para escribir aplicaciones más manejables. Es un poco como predecir el futuro y dibujar marcos de las acciones posibles.
Trabajar con la máquina de estado
Similar al ejemplo del principio, tenemos que definir los posibles estados (finitos) de nuestra máquina y describir las posibles entradas:
import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });
Tenemos nuestro estado inicial, idle
, que acepta una acción de run
. Una vez que la máquina está en running
, podemos activar la acción de stop
, que nos devuelve al estado idle
.
Probablemente recordará los ayudantes de dispatch
y changeStateTo
de nuestra implementación anterior. Esta biblioteca proporciona la misma lógica, pero está oculta internamente y no tenemos que pensar en ella. Por comodidad, en función de la propiedad de las transitions
, Stent genera lo siguiente:
- métodos auxiliares para verificar si la máquina está en un estado particular: el estado
idle
produce el métodoisIdle()
, mientras que pararunning
tenemosisRunning()
; - métodos auxiliares para enviar acciones:
runPlease()
ystopNow()
.
Entonces, en el ejemplo anterior, podemos usar esto:
machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action
Combinando los métodos generados automáticamente con la función de utilidad de connect
, podemos cerrar el círculo. Una interacción del usuario activa la entrada y la acción de la máquina, que actualiza el estado. Debido a esa actualización, la función de mapeo pasada para connect
se activa y se nos informa sobre el cambio de estado. Luego, volvemos a renderizar.
Controladores de entrada y acción
Probablemente la parte más importante son los controladores de acción. Este es el lugar donde escribimos la mayor parte de la lógica de la aplicación porque estamos respondiendo a las entradas y estados modificados. Algo que me gusta mucho en Redux también está integrado aquí: la inmutabilidad y la simplicidad de la función reductora. La esencia del manejador de acciones de Stent es la misma. Recibe el estado actual y la carga útil de la acción, y debe devolver el nuevo estado. Si el controlador no devuelve nada ( undefined
), entonces el estado de la máquina permanece igual.
transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }
Supongamos que necesitamos obtener datos de un servidor remoto. Activamos la solicitud y hacemos la transición de la máquina a un estado de fetching
. Una vez que los datos provienen del back-end, activamos una acción success
, así:
machine.success({ label: '...' });
Luego, volvemos a un estado idle
y mantenemos algunos datos en forma de matriz todos
. Hay un par de otros valores posibles para establecer como controladores de acción. El primer caso y el más simple es cuando pasamos solo una cadena que se convierte en el nuevo estado.
transitions: { 'idle': { 'run': 'running' } }
Esta es una transición de { name: 'idle' }
a { name: 'running' }
mediante la acción run()
. Este enfoque es útil cuando tenemos transiciones de estado sincrónicas y no tenemos metadatos. Entonces, si mantenemos algo más en estado, ese tipo de transición lo eliminará. De manera similar, podemos pasar un objeto de estado directamente:
transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: [] } } }
Estamos pasando de la editing
a idle
mediante la acción deleteAllTodos
.
Ya vimos el controlador de funciones, y la última variante del controlador de acciones es una función generadora. Está inspirado en el proyecto Redux-Saga, y se ve así:
import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } } });
Si no tiene experiencia con generadores, esto puede parecer un poco críptico. Pero los generadores en JavaScript son una herramienta poderosa. Se nos permite pausar nuestro controlador de acciones, cambiar el estado varias veces y manejar la lógica asíncrona.
Diversión con generadores
Cuando me presentaron Redux-Saga por primera vez, pensé que era una forma demasiado complicada de manejar las operaciones asíncronas. De hecho, es una implementación bastante inteligente del patrón de diseño de comandos. El principal beneficio de este patrón es que separa la invocación de la lógica y su implementación real.
En otras palabras, decimos lo que queremos pero no cómo debe suceder. La serie de blogs de Matt Hink me ayudó a comprender cómo se implementan las sagas y recomiendo enfáticamente leerla. Incorporé las mismas ideas a Stent y, para el propósito de este artículo, diremos que al ceder cosas, estamos dando instrucciones sobre lo que queremos sin hacerlo realmente. Una vez realizada la acción, recibimos el control de vuelta.
Por el momento, se pueden enviar (rendir) un par de cosas:
- un objeto de estado (o una cadena) para cambiar el estado de la máquina;
- una llamada del asistente de
call
(acepta una función síncrona, que es una función que devuelve una promesa u otra función generadora): básicamente estamos diciendo: “Ejecuta esto por mí, y si es asíncrono, espera. Una vez que termines, dame el resultado.”; - una llamada del ayudante de
wait
(acepta una cadena que representa otra acción); si usamos esta función de utilidad, pausamos el controlador y esperamos a que se envíe otra acción.
Aquí hay una función que ilustra las variantes:
const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }
Como podemos ver, el código parece sincrónico, pero en realidad no lo es. Es solo Stent haciendo la parte aburrida de esperar la promesa resuelta o iterando sobre otro generador.
Cómo Stent está resolviendo mis preocupaciones de Redux
Demasiado código repetitivo
La arquitectura Redux (y Flux) se basa en acciones que circulan en nuestro sistema. Cuando la aplicación crece, normalmente acabamos teniendo muchas constantes y creadores de acciones. Estas dos cosas están muy a menudo en carpetas diferentes, y el seguimiento de la ejecución del código a veces lleva tiempo. Además, al agregar una nueva característica, siempre tenemos que lidiar con un conjunto completo de acciones, lo que significa definir más nombres de acciones y creadores de acciones.
En Stent, no tenemos nombres de acciones, y la biblioteca crea los creadores de acciones automáticamente para nosotros:
const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } } }); machine.addTodo({ title: 'Fix that bug' });
Tenemos el creador de la acción machine.addTodo
definido directamente como un método de la máquina. Este enfoque también resolvió otro problema al que me enfrenté: encontrar el reductor que responde a una acción en particular. Por lo general, en los componentes de React, vemos nombres de creadores de acciones como addTodo
; sin embargo, en los reductores trabajamos con un tipo de acción que es constante. A veces tengo que saltar al código del creador de la acción solo para poder ver el tipo exacto. Aquí, no tenemos tipos en absoluto.
Cambios de estado impredecibles
En general, Redux hace un buen trabajo al administrar el estado de manera inmutable. El problema no está en Redux en sí, sino en que el desarrollador puede enviar cualquier acción en cualquier momento. Si decimos que tenemos una acción que enciende las luces, ¿está bien disparar esa acción dos veces seguidas? Si no, ¿cómo se supone que vamos a resolver este problema con Redux? Bueno, probablemente pondríamos algún código en el reductor que proteja la lógica y que verifique si las luces ya están encendidas, tal vez una cláusula if
que verifique el estado actual. Ahora la pregunta es, ¿no está esto más allá del alcance del reductor? ¿Debería el reductor conocer tales casos extremos?
Lo que me falta en Redux es una forma de detener el envío de una acción en función del estado actual de la aplicación sin contaminar el reductor con lógica condicional. Y tampoco quiero llevar esta decisión a la capa de vista, donde se dispara el creador de la acción. Con Stent, esto sucede automáticamente porque la máquina no responde a acciones que no están declaradas en el estado actual. Por ejemplo:
const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();
El hecho de que la máquina acepte solo entradas específicas en un momento dado nos protege de errores extraños y hace que nuestras aplicaciones sean más predecibles.
Estados, no transiciones
Redux, como Flux, nos hace pensar en términos de transiciones. El modelo mental de desarrollar con Redux se basa en gran medida en las acciones y cómo estas acciones transforman el estado en nuestros reductores. Eso no está mal, pero descubrí que tiene más sentido pensar en términos de estados: en qué estados podría estar la aplicación y cómo estos estados representan los requisitos comerciales.
Conclusión
El concepto de máquinas de estado en la programación, especialmente en el desarrollo de IU, me abrió los ojos. Empecé a ver máquinas de estado en todas partes y tengo cierto deseo de cambiar siempre a ese paradigma. Definitivamente veo los beneficios de tener estados más estrictamente definidos y transiciones entre ellos. Siempre estoy buscando formas de hacer que mis aplicaciones sean simples y legibles. Creo que las máquinas de estado son un paso en esa dirección. El concepto es simple y al mismo tiempo poderoso. Tiene el potencial de eliminar muchos errores.