DEV Community

Heiker
Heiker

Posted on • Edited on

Un vistazo a las máquinas de estados finitos

¿Máquinas de qué-- quién?

Las máquinas de estados finitos son una manera de modelar el comportamiento de un sistema. La idea es que tu "sistema" sólo puede encontrarse en un estado a la vez, y una entrada (evento) puede activar la transición a otro estado.

¿Qué tipo de problemas resuelven?

Estados inválidos. ¿Cuántas veces han tenido que usar una variable con un booleano o un atributo como "disabled" para evitar que un usuario haga algo indebido? Al marcar las reglas de comportamiento por adelantado podemos evitar este tipo de cosas.

¿Cómo se hace eso en javascript?

Me alegra que preguntaran. La verdadera razón por la escribo esto es para mostrar una librería que vi el otro día. Vamos a usar robot3 para crear un máquina de frases semi-famosas.

Lo que haremos será mostrar una "carta" con una frase y debajo de tendremos un botón que podremos usar para mostrar otra frase.

Haremos esto un paso a la vez. Primero preparemos los posibles estados de la aplicación.

Nuestra carta estará en estado idle (algo así como 'esperando') o loading (cargando) Crearemos nuestra máquina a partir de eso.

import {
  createMachine,
  state,
  interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';

const mr_robot = createMachine({
  idle: state(),
  loading: state()
});
Enter fullscreen mode Exit fullscreen mode

Aquí cada estado es un índice del "objeto de configuración" que le pasamos a createMachine, vean que cada uno de estos índices deben ser el resultado de llamar la función state.

Ahora necesitamos transiciones. El estado idle cambiará a estado loading si ocurre un evento fetch (buscar), loading volverá a idle cuando el evento done (terminado) sea despachado.

 import {
  createMachine,
  state,
+ transition,
  interpret
 } from 'https://unpkg.com/robot3@0.2.9/machine.js';

const mr_robot = createMachine({
-  idle: state(),
-  loading: state()
+  idle: state(transition('fetch', 'loading')),
+  loading: state(transition('done', 'idle'))
 });
Enter fullscreen mode Exit fullscreen mode

transition es lo que conecta los estados. El primer parámetro que recibe es el nombre del evento que lo activará, el segundo parámetro es el "evento destino" al cual cambiará. El resto de los parámetros consiste en una de funciones que serán ejecutadas cuando ocurra la transición.

Luce bien y todo pero... uhm... ¿cómo hacemos pruebas? Por sí sola la máquina no hace nada. Necesitamos que nuestra máquina sea interpretada y para ello se la pasamos a la función interpret, esta función nos devuelve un "servicio" con el cual podemos despachar eventos. Para asegurarnos que de verdad estamos haciendo algo vamos a usar el segundo parámetro de interpret el cual será una función que "escuchará" los cambios de estado.

const handler = ({ machine }) => {
  console.log(machine.current);
}

const { send } = interpret(mr_robot, handler);
Enter fullscreen mode Exit fullscreen mode

Ahora veamos si está viva.

send('fetch');
send('fetch');
send('fetch');
send('done');

// Deberían ver en la cónsola
// loading (3)
// idle
Enter fullscreen mode Exit fullscreen mode

Despachar fetch hace que el estado actual se convierta en loading y despachardone lo regresa a idle. Veo que no están impresionados. Bien. Intentemos algo más. Agregemos otro estado end y hagamos que loading cambie a ese, luego despachamos done y vemos qué pasa.

 const mr_robot = createMachine({
   idle: state(transition('fetch', 'loading')),
-   loading: state(transition('done', 'idle'))
+   loading: state(transition('done', 'end')),
+   end: state()
 });
Enter fullscreen mode Exit fullscreen mode
send('done');

// Deberían ver en la cónsola
// idle
Enter fullscreen mode Exit fullscreen mode

Enviar done mientras el estado es idle no activa el estado loading, se queda en idle porque ese estado no tiene un evento done. Y ahora...

// El curso normal de eventos.

send('fetch');
send('done');

// Deberían ver en la cónsola
// loading
// end

// Intenten con `fetch`
send('fetch');

// Ahora...
// end
Enter fullscreen mode Exit fullscreen mode

Si enviamos fetch (o cualquier otro evento) mientras el estado es end resultará en end siempre. ¿Por qué? Porque no hay a dónde ir, end no tiene transiciones.

Espero que les haya sido útil, si no fue así me disculpo por tanto console.log.

Volvamos a nuestra máquina. Esto es lo que tenemos hasta ahora.

 import {
  createMachine,
  state,
  transition,
  interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';

const mr_robot = createMachine({
  idle: state(transition('fetch', 'loading')),
  loading: state(transition('done', 'idle'))
});

const handler = ({ machine }) => {
  console.log(machine.current);
}

const { send } = interpret(mr_robot, handler);
Enter fullscreen mode Exit fullscreen mode

Pero aún no es suficiente, ahora debemos extraer datos de alguna parte cuando el estado sea loading. Vamos a fingir que buscamos los datos en nuestra función.

function get_quote() {
  // crea un retraso de 3 a 5 segundos.
  const delay = random_number(3, 5) * 1000;

  const promise = new Promise(res => {
    setTimeout(() => res('<quote>'), delay);
  });

  // nomás pa' ver
  promise.then(res => (console.log(res), res));

  return promise;
}
Enter fullscreen mode Exit fullscreen mode

Para integrar esta función a nuestra máquina vamos a usar la función invoke, esta nos ayuda a manejar "funciones asíncronas" (una función que devuelve una promesa) cuando se active el estado, luego cuando la promesa se resuelve envía el evento done (si algo falla envía el evento error).

  import {
   createMachine,
   state,
+  invoke,
   transition,
   interpret
 } from 'https://unpkg.com/robot3@0.2.9/machine.js';

 const mr_robot = createMachine({
   idle: state(transition('fetch', 'loading')),
-  loading: state(transition('done', 'idle')),
+  loading: invoke(get_quote, transition('done', 'idle')),
 });
Enter fullscreen mode Exit fullscreen mode

Si prueban send('fetch') deberían ver en la cónsola.

loading

// Esperen unos segundos...

<quote>
idle
Enter fullscreen mode Exit fullscreen mode

Espero que estas alturas se estén preguntando ¿Y dónde guardamos los datos? createMachine nos deja definir un "contexto" que estará disponible para nosotros en las función que apliquemos en las transiciones.

const context = ev => ({
  data: {},
});
Enter fullscreen mode Exit fullscreen mode
  const mr_robot = createMachine({
    idle: state(transition('fetch', 'loading')),
    loading: invoke(get_quote, transition('done', 'idle')),
- });
+ }, context);
Enter fullscreen mode Exit fullscreen mode

Ahora agregaremos una función a nuestra transición loading. Será el lugar donde modificaremos el context. Esta función es llamada reduce y luce así.

reduce((ctx, ev) => ({ ...ctx, data: ev.data }))
Enter fullscreen mode Exit fullscreen mode

Recibe el context actual, una carga (aquí la llamamos ev) y lo que sea que devuelva se convertirá en tu nuevo contexto.

  import {
   createMachine,
   state,
   invoke,
   transition,
+  reduce,
   interpret
 } from 'https://unpkg.com/robot3@0.2.9/machine.js';

 const mr_robot = createMachine({
   idle: state(transition('fetch', 'loading')),
-  loading: invoke(get_quote, transition('done', 'idle')), 
+  loading: invoke(
+    get_quote, 
+    transition(
+      'done',
+      'idle',
+      reduce((ctx, ev) => ({ ...ctx, data: ev.data }))
+    )
+  ),
 }, context);
Enter fullscreen mode Exit fullscreen mode

Hora de probar. ¿Cómo lo hacemos? Modificamos el callback de interpret.

const handler = ({ machine, context }) => {
  console.log(JSON.stringify({ 
    state: machine.current,
    context
  }));
}
Enter fullscreen mode Exit fullscreen mode

Deberían ver esto.

{'state':'loading','context':{'data':{}}}

// esperen unos segundos...

{'state':'idle','context':{'data':'<quote>'}}
Enter fullscreen mode Exit fullscreen mode

Estamos listos. Mostremos algo en el navegador.

<main id="app" class="card">
  <section id="card" class="card__content">
     <div class="card__body">
        <div class="card__quote">
          quote
        </div>

        <div class="card__author">
          -- author
        </div>
      </div>
      <div class="card__footer">
        <button id="load_btn" class="btn btn--new">
          More
        </button>
        <a href="#" target="_blank" class="btn btn--tweet">
          Tweet
        </a>
      </div> 
  </section> 
</main>
Enter fullscreen mode Exit fullscreen mode
body {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 95vh;
  background: #ddd;
  font-size: 1em;
  color: #212121;
}

.card {
  width: 600px;
  background: white;
  box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
}

.card__content {
  color: #212121;
  padding: 20px;
}

.card__content--loader {
  height: 95px;
  display: flex;
  align-items: center;
  justify-content: center
}

.card__body {
 padding-bottom: 15px;
}

.card__author {
  padding-top: 10px;
  font-style: italic;
}

.card__footer {
  width: 100%;
  display: flex;
  justify-content: space-between;
}

.btn {
  color: #fff;
  cursor: pointer;
  margin-top: 10px;
  margin-left: 10px;
  border-radius: 0.4rem;
  text-decoration: none;
  display: inline-block;
  padding: .3rem .9rem;
}

.btn--new {
  background-color: #2093be;
  border: 0.1rem solid #2093be;

}

.btn--tweet {
  background-color: #0074d9;
  border: 0.1rem solid #0074d9;
}

.btn:hover {
  background: #3cb0fd;
  border: 0.1rem solid #3cb0fd;
  text-decoration: none;
}

.hide {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

La última pieza del rompecabezas, los efectos secundarios. Necesitamos agregar otra función a la transición loading para poder actualizar el DOM. Podríamos usar reduce nuevamente pero es de mala educación hacer eso en algo que se llame reduce. Utilizaremos otra función, una llamada action.

Pero primero debemos preprarnos. Modificaremos el contexto con las dependencias necesarias. (Este paso es innecesario, esto es sólo por mi alergia a las variables globales)

 const context = ev => ({
   data: {},
+  dom: {
+    quote: document.querySelector('.card__quote'),
+    author: document.querySelector('.card__author'),
+    load_btn: window.load_btn,
+    tweet_btn: document.querySelector('.btn--tweet'),
+    card: window.card
+  }
 });
Enter fullscreen mode Exit fullscreen mode

Ahora sí, efectos secundarios. En este punto deberían asegurarse que get_quote devuelva un objeto con las propiedades quote y author.

function update_card({ dom, data }) {
  dom.load_btn.textContent = 'More';
  dom.quote.textContent = data.quote;
  dom.author.textContent = data.author;

  const web_intent = 'https://twitter.com/intent/tweet?text=';
  const tweet = `${data.quote} -- ${data.author}`;
  dom.tweet_btn.setAttribute(
    'href', web_intent + encodeURIComponent(tweet)
  );
}

function show_loading({ dom }) {
  dom.load_btn.textContent = 'Loading...';
}
Enter fullscreen mode Exit fullscreen mode

Juntamos todo.

  import {
   createMachine,
   state,
   invoke,
   transition,
   reduce,
+  action,
   interpret
 } from 'https://unpkg.com/robot3@0.2.9/machine.js';

 const mr_robot = createMachine({
-  idle: state(transition('fetch', 'loading')),
+  idle: state(transition('fetch', 'loading', action(show_loading))),
   loading: invoke(
     get_quote, 
     transition(
       'done',
       'idle',
       reduce((ctx, ev) => ({ ...ctx, data: ev.data })),
+      action(update_card)
     )
   ),
 }, context);
Enter fullscreen mode Exit fullscreen mode

Funciona. Pero se ve mal cuando carga por primera vez. Hagamos otra transición de carga, una que esconda la carta mientras se carga la primera frase.

Empecemos por el HTML.

 <main id="app" class="card">
+  <section class="card__content card__content--loader"> 
+    <p>Loading</p> 
+  </section>
-  <section id="card" class="card__content">
+  <section id="card" class="hide card__content">
     <div class="card__body">
       <div class="card__quote">
         quote
       </div>

       <div class="card__author">
          -- author
       </div>
     </div>
     <div class="card__footer">
       <button id="load_btn" class="btn btn--new">
         More
       </button>
       <a href="#" target="_blank" class="btn btn--tweet">
         Tweet
       </a>
     </div> 
   </section> 
 </main>
Enter fullscreen mode Exit fullscreen mode

Creamos otro estado, empty. Podemos reusar la lógica del estado loading para esto. Creamos una función que crea transiciones.

const load_quote = (...args) =>
  invoke(
    get_quote,
    transition(
      'done',
      'idle',
      reduce((ctx, ev) => ({ ...ctx, data: ev.data })),
      ...args
    ),
    transition('error', 'idle')
  );
Enter fullscreen mode Exit fullscreen mode
 const mr_robot = createMachine({
   idle: state(transition('fetch', 'loading', action(show_loading))),
-  loading: invoke(
-    get_quote, 
-    transition(
-      'done',
-      'idle',
-      reduce((ctx, ev) => ({ ...ctx, data: ev.data })),
-      action(update_card)
-    )
-  ),
+  loading: load_quote(action(update_card))
 }, context);
Enter fullscreen mode Exit fullscreen mode

Ahora la usamos para esconder el esqueleto de la carta en la primera carga y muestre la frase cuando esté lista.

 const context = ev => ({
   data: {},
   dom: {
     quote: document.querySelector('.card__quote'),
     author: document.querySelector('.card__author'),
+    loader: document.querySelector('.card__content--loader'),
     load_btn: window.load_btn,
     tweet_btn: document.querySelector('.btn--tweet'),
     card: window.card
   }
 });
Enter fullscreen mode Exit fullscreen mode
function hide_loader({ dom }) {
  dom.loader.classList.add('hide');
  dom.card.classList.remove('hide');
}
Enter fullscreen mode Exit fullscreen mode
 const mr_robot = createMachine({
+  empty: load_quote(action(update_card), action(hide_loader)),
   idle: state(transition('fetch', 'loading', action(show_loading))),
   loading: load_quote(action(update_card))
 }, context);
-
- const handler = ({ machine, context }) => {
-  console.log(JSON.stringify({ 
-    state: machine.current,
-    context
-  }));
- }
+ const handler = () => {};

 const { send } = interpret(mr_robot, handler);
+
+ const fetch_quote = () => send('fetch');
+
+ window.load_btn.addEventListener('click', fetch_quote);
Enter fullscreen mode Exit fullscreen mode

Veamos cómo quedó.

¿Entonces esto de la máquina de estados finitos fue útil?

Eso espero. ¿Notaron que nos permitió hacer un montón de pruebas y planear el comportamiento incluso antes de crear el HTML? Me parece que eso es genial.

¿Intentaron darle click al botón 'loading' mientras cargaba? ¿Causó llamadas repetidas a get_quote? Eso es porque hicimos que fuera (casi) imposible que el evento fetch ocurriera durante loading.

No sólo eso, el comportamiento de la máquina y sus efectos en el mundo exterior están separados. Esto puede ser bueno o malo para ustedes pero eso depende de su tendencia filosófica.

¿Quieren saber más?

(me perdonan que todo estos sean en inglés.)

XState (concepts)
robot3 - docs
Understanding State Machines


Gracias por su tiempo. Si este artículo les pareció útil y quieren apoyar mis esfuerzos para crear más contenido pueden dejar una propina en ko-fi.com/vonheikemen.

buy me a coffee

Top comments (0)