Arquitectura de interfaces web: Parte 3

Estado / HOCs / Store

Lupo Montero
Laboratoria Devs
12 min readJul 24, 2018

--

Este post es parte de la serie Arquitectura de interfaces web. Si todavía no has leído las partes 1 (Arquitectura, TDD, entorno de desarrollo y herramientas) y 2 (Vistas, componentes y renderizado), te recomiendo empezar por ahí 😉

Seguimos con la aplicación de ejemplo que empezamos en los posts anteriores de esta serie. Recapitulando, estamos construyendo una aplicación que responde al siguiente storyboard:

Primer storyboard de nuestra aplicación de ejemplo

Por ahora hemos implementado el componente (vista) SignIn — que usa el componente SignInButton, que a su vez usa el componente Button— y lo hemos pintado en la pantalla por medio de una función render.

Nuestra arquitectura, hasta el momento, se podría representar en un diagrama así:

El diagrama expresa que las vistas están compuestas de otras vistas, y que las podemos llevar a la pantalla por medio de la función render. Esto sería suficiente para construir una interfaz estática, sin data ni interacción. Pero ese no es el objetivo final. En este post vamos a ver como podemos agregar data (estado), interacción y un mecanismo para que estas interacciones puedan afectar al estado y esto se pueda ver reflejado de vuelta en la pantalla (vamos a pasar de un flujo lineal a uno cíclico).

Es hora de hablar de data e interacción ⭐️

Estado

Photo by Dmitri Popov on Unsplash

En el contexto de una interfaz, cuando hablamos de estado (state en inglés) nos referimos a la data necesaria para materializar o renderizar la interfaz. Este estado es el que determina qué mostramos y qué opciones de interacción ofrecemos.

ℹ Como aclaración, en esta serie nos estamos centrando en arquitectura de interfaces, no de una aplicación web completa con back-end, base de datos, … En el contexto más amplio de una aplicación o sistema web, vamos a encontrar el concepto de estado en varias capas distintas: a nivel de la base datos (Record State), a nivel del servidor (Session Sate) y finalmente a nivel de la interfaz (Screen State). Cuando hablemos de estado de aquí en adelante, nos referimos al estado de pantalla o screen state ya que estamos en el contexto de la interfaz.

Representando estado como data

En nuestro ejemplo, al analizar nuestro storyboard dijimos que los dos primeros rectángulos eran dos estados de la misma vista. La vista en sí es el componente SignIn, pero esta vista puede mostrar un botón normal o el mismo botón en estado deshabilitado (disabled). Cuál sería la data mínima para representar estos dos estados? Usemos un booleano…

{ loading: true }

La propiedad loading de este objeto nos sirve para expresar si el usuario ya apretó el botón de inicio de sesión o no. Con este objeto tenemos suficiente información para saber si el botón debe estar habilitado o deshabilitado a la hora de renderizar la interfaz. Si pudiéramos darle esta información a nuestro componente SignIn, este podría encargarse de cómo se le comunica esto al usuario.

Para continuar, agreguemos la funcionalidad necesaria para que nuestros componentes puedan mostrar el botón en ambos estados. Empecemos por el componente Button en el archivo src/components/Button.js:

import createElement from '../lib/createElement';export default props => createElement('button', {
innerText: props.text,
disabled: !!props.disabled,
});

Nuestra implementación anterior ya tenía un argumento props, así que simplemente asignamos el valor de props.disabled (convertido a booleano) sobre el atributo disabled del botón.

Invoquemos nuestros tests otra vez, pero ahora usando la opción --watch que nos ofrece Jest.

  • --watch: Vigila nuestros archivos y ejecuta los tests automáticamente cuando salvamos cambios. Esto va a hacer que Jest no nos devuelva el prompt, sino que se queda escuchando cambios, así que dejemos la consola abierta y a la vista, o ejecutemos el comando dentro del terminal de nuestro editor o IDE (si es que lo tiene), para tener presente el output de los tests mientras programamos.
npx jest --watch

Por ahora nuestro test sigue pasando. Ahora nos toca agregar la funcionalidad necesaria en nuestro SignInButton en el archivo src/components/SignInButton.js:

import Button from './Button';export default props => Button({
text: 'Sign in',
disabled: !!props.disabled,
});

Al salvar el archivo, deberíamos ver que nuestro test ahora revienta 💣

Esto es porque la implementación anterior de SignInButton no esperaba ningún argumento, y ahora esperamos el objeto props. En nuestro componente SignIn estamos invocando SignInButton sin pasarle nada! Arreglemos esto rápidamente en el archivo src/pages/SignIn.js:

import createElement from '../lib/createElement';
import SignInButton from '../components/SignInButton';
export default props => createElement('div', {
className: 'sign-in',
children: [SignInButton({ disabled: !!props.loading })],
});

Cuando salvemos los cambios, deberíamos seguir viendo un error en nuestros tests, pero un nuevo error (ya no en SignInButton, sino en SignIn).

En nuestro test estamos invocando SignIn sin pasarle nada, y ahora espera un objeto como argumento. Para solucionar esto, en test/pages/SignIn.spec.js vamos a pasar un objeto vacío cuando invoquemos a SignIn, reemplazando SignIn() por SignIn({}).

const el = SignIn({});

Salvamos y …

Nuestro test vuelve a pasar!

Ahora agreguemos unos tests para describir el comportamiento esperado del botón según el estado en el archivo test/pages/SignIn.spec.js:

import SignIn from '../../src/pages/SignIn';describe('SignIn', () => {
it('should return div with nested button', () => {
const el = SignIn({});
expect(el.tagName).toBe('DIV');
expect(el.className).toBe('sign-in');
expect(el.children.length).toBe(1);
expect(el.children[0].tagName).toBe('BUTTON');
expect(el.children[0].innerText).toBe('Sign in');
});
it('should render enabled button by default', () => {
const el = SignIn({});
expect(el.children[0].disabled).toBe(false);
});

it('should render enabled button when loading is false', () => {
const el = SignIn({ loading: false });
expect(el.children[0].disabled).toBe(false);
});

it('should render disabled button when loading is true', () => {
const el = SignIn({ loading: true });
expect(el.children[0].disabled).toBe(true);
});
});

Hemos añadido 3 nuevos tests; el primero verifica que el botón aparezca habilitado por defecto (cuando no especificamos props.loading), el segundo que esté habilitado cuando explícitamente pasemos { loading: false }, y finalmente que al pasar { loading: true } el botón aparezca deshabilitado.

Nuestra implementación ya pasa todas estas pruebas! 🎉

HOCs

Nuestros tests ahora le están pasando un argumento (props) al componente SignIn, pero cómo le podemos pasar estas props cuando lo invoca render en nuestra aplicación sin que render tenga que saber nada al respecto? Higher-order components (HOCs) al rescate!! 🚁

Un HOC es básicamente una función que recibe un componente como argumento y/o retorna un componente. Los componentes son funciones, así que se trata de Funciones de orden superior (Higher-order Functions — HOFs), y de ahí el nombre. Si no estás familiarizada con el concepto de HOFs, te recomiendo leer este otro artículo: Introducción a la programación funcional en JavaScript — Parte 3: Composición.

De esta forma podemos crear componentes a partir de otros componentes y componerlos para darles habilidades nuevas. Veamos cómo se vería la firma de un HOC:

Component withSuperpowers(Component)

Esta firma (signature) nos dice que withSuperpowers es una función que recibe un componente como argumento y retorna un componente. También vale la pena mencionar el nombre que le hemos dado a la función (withSuperpowers“con superpoderes” en español) que usamos para indicar que recibimos un componente y retornamos un componente equivalente al recibido, pero con algo añadido (los superpoderes en este caso).

Usando este patrón podemos inyectar propiedades o argumentos a nuestras funciones (componentes). Agreguemos una función con el nombre withState en nuestro src/index.js y usémosla para crear un nuevo componente que use SignIn y le agregue una propiedad state:

import render from './lib/render';
import SignIn from './pages/SignIn';
// EL HOC que agrega la propiedad `state`.
const withState = Component => props => Component({
...props,
state: { loading: false },
});
// En vez de renderizar `SignIn`, creamos un nuevo componente
// usando el HOC pasándole el componente `SignIn`. Esto nos
// da un componente que al ser invocado invovará a `SignIn`
// pasándole la propiedad `state`.
render(withState(SignIn), document.getElementById('root'));

La función withState recibe un argumento (Component) y retorna una función (otro componente) que recibe un argumento (props) y retorna lo que retorne invocar al componente recibido por la primera función (Component) pasándole las props recibidas por la segunda función agregándole una nueva propiedad state.

Con esta función podríamos agregar la propiedad state a cualquier componente.

Ahora actualicemos nuestro componente SignIn para que use props.state.loading en vez de props.loading:

import createElement from '../lib/createElement';
import SignInButton from '../components/SignInButton';
export default props => createElement('div', {
className: 'sign-in',
children: [SignInButton({ disabled: !!props.state.loading })],
});

Al salvar el archivo deberíamos ver que nuestros tests revientan… 😱

Esto es porque ahora tenemos que agregar la propiedad state cuando invoquemos a SignIn en nuestros tests. Arreglemos esto en test/pages/SignIn.spec.js:

import SignIn from '../../src/pages/SignIn';describe('SignIn', () => {
it('should return div with nested button', () => {
const el = SignIn({ state: {} });
expect(el.tagName).toBe('DIV');
expect(el.className).toBe('sign-in');
expect(el.children.length).toBe(1);
expect(el.children[0].tagName).toBe('BUTTON');
expect(el.children[0].innerText).toBe('Sign in');
});
it('should render enabled button by default', () => {
const el = SignIn({ state: {} });
expect(el.children[0].disabled).toBe(false);
});
it('should render enabled button when loading is false', () => {
const el = SignIn({ state: { loading: false } });
expect(el.children[0].disabled).toBe(false);
});
it('should render disabled button when loading is true', () => {
const el = SignIn({ state: { loading: true } });
expect(el.children[0].disabled).toBe(true);
});
});

Salvamos y …

Todo pasa otra vez! Ahora podemos pintar el botón dependiendo de la data de state, y podemos probar manualmente (cambiando el valor de state.loading en la implementación de withState) para ver cómo se ve la interfaz. El siguiente paso va a ser poder manipular esa data y afectarla como resultado de interacción con el usuario.

Actualicemos nuestro diagrama agregando el concepto de estado y los HOC como mecanismo para dar habilidades (el estado en este caso).

Llegado a este punto podríamos renderizar data en una vista, pero cómo podemos hacer para que esa data, el estado, vaya cambiando a lo largo de la ejecución de la aplicación?

Store

Para manejar el estado de nuestra aplicación, lo vamos a envolver en un objeto que llamaremos store, que nos permita hacer lo siguiente:

  • encapsular la data del estado (darnos acceso para leer y modificar el estado a través de métodos).
  • notificar a otras partes de la aplicación cuando hayan cambios de estado.

Implementemos una función para crear un objeto con una interfaz que nos permita hacer todo esto.

store createStore(initialState)

Esta firma nos dice que la función createStore va a recibir un argumento (initialState) y retorna un objeto store. Implementemos esta función en src/lib/createStore.js:

export default (initialState = {}) => {
let state = { ...initialState };
const listeners = [];
return {
getState: () => state,
setState: (newState) => {
state = { ...state, ...newState };
listeners.forEach(fn => fn());
},
subscribe: (fn) => {
listeners.push(fn);
},
};
};

En esta implementación de createStore vamos a usar dos variables locales (state y listeners) y retornar un objeto con tres métodos que nos van a permitir leer/escribir el estado así como recibir notificaciones cuando ocurran cambios en el estado. Este objeto que retornamos, es el objeto que llamaremos store y tiene los siguientes métodos:

  • getState: no espera argumentos y devuelve el estado actual.
  • setState: espera un objeto con las propiedades que queremos cambiar en el estado, las combina con el estado anterior para crear una nueva versión del estado y notifica a subscriptores.
  • subscribe: permite suscribir funciones que serán invocadas cuando ocurran cambios (cuando se invoque setState).

Agregando store en vistas por medio de un HOC

Usando el mismo mecanismo que vimos con el HOC withState, podemos cambiar nuestra implementación para que agregue el store a nuestros componentes, en vez de un objeto state directamente. Modifiquemos e archivo src/index.js:

import render from './lib/render';
import createStore from './lib/createStore';
import SignIn from './pages/SignIn';
const store = createStore({ loading: false });const withStore = Component => props => Component({
...props,
store,
});
const doRender = () => render(
withStore(SignIn),
document.getElementById('root'),
);
store.subscribe(doRender);
doRender();

En esta nueva versión de src/index.js hemos anidado la invocación a render dentro de otra función (doRender), la cual vamos a suscribir al store para que sea ejecutada cada vez que ocurran cambios en el estado. También hemos añadido una invocación a esta función doRender al final del script para renderizar la interfaz al cargar la página, antes de que ocurran cambios en el store. Si no, doRender solo se invocaría una vez que hayan cambios en el store.

Ahora actualicemos nuestros tests para que hagan uso de un store en vez de un objeto plano (state). En todas las invocaciones a SignIn, ahora le vamos a pasar un store creado con nuestra función createStore.

import SignIn from '../../src/pages/SignIn';
import createStore from '../../src/lib/createStore';
describe('SignIn', () => {
it('should return div with nested button', () => {
const el = SignIn({ store: createStore() });
expect(el.tagName).toBe('DIV');
expect(el.className).toBe('sign-in');
expect(el.children.length).toBe(1);
expect(el.children[0].tagName).toBe('BUTTON');
expect(el.children[0].innerText).toBe('Sign in');
});
it('should render enabled button by default', () => {
const el = SignIn({ store: createStore() });
expect(el.children[0].disabled).toBe(false);
});
it('should render enabled button when loading is false', () => {
const el = SignIn({ store: createStore({ loading: false }) });
expect(el.children[0].disabled).toBe(false);
});
it('should render disabled button when loading is true', () => {
const el = SignIn({ store: createStore({ loading: true }) });
expect(el.children[0].disabled).toBe(true);
});
});

Salvamos los tests y …

Nuestros tests nos avisan que no se puede leer la propiedad loading de undefined. Esto es porque nuestros componentes esperan una propiedad state y ahore les estamos pasando store. Arreglemos esto en src/pages/SignIn.js:

import createElement from '../lib/createElement';
import SignInButton from '../components/SignInButton';
export default props => createElement('div', {
className: 'sign-in',
children: [
SignInButton({ disabled: !!props.store.getState().loading }),
],
});

Lo único que cambia es que ahora accedemos al estado a través de props.store.getState() en vez de acceder directamente al objeto props.state. Si volvemos a salvar cambios verás que los tests ya pasan 😉.

Agregando interacción y modificando el estado

Ahora que ya tenemos el store disponible en nuestros componentes estamos listos para agregar interacción 🔥

La primera interacción que vamos a implementar es simplemente que cuando el usuario haga click en el botón de inicio de sesión, actualicemos el estado de la interfaz (queremos que loading pase de false a true), y esto debería disparar un nuevo render automáticamente (gracias a que suscribimos doRender con store.subscribe).

Agreguemos un test en test/pages/SignIn.spec.js para describir este comportamiento.

it('should set loading to true when button is clicked', () => {
const store = createStore({ loading: false });
const el = SignIn({ store });
el.children[0].click();
expect(store.getState().loading).toBe(true);
});

En nuestro tests invocamos el método .click() del botón para disparar el evento click que notificará a nuestro onclick. Esto debería a su vez invocar store.setState.

Cuando salvamos nuestros cambios nuestro nuevo tests debe mostrar un error ya que todavía no hemos implementado la interacción en nuestros componentes.

Para implementar esta interacción vamos a querer escuchar el evento onclick del botón. Así que agreguemos una propiedad onclick a nuestros componentes Button y SignInButton para que podamos pasarles un callback y asignarlo al atributo onclick del elemento del DOM (el botón — <button>).

src/components/Button.js:

import createElement from '../lib/createElement';export default ({ text, disabled, onclick }) => (
createElement('button', {
innerText: text,
disabled,
onclick,
})
);

Ahora que hemos agregado onclick a las props, aprovechamos a desestructurar las props ({ text, diabled, onClick }) y usamos el estilo shorthand para asignar estas props al objeto que le pasamos a createElement.

Actualicemos el componente SignInButton para que espere onclick en sus props y se la pase a Button.

src/components/SignInButton.js:

import Button from './Button';export default ({ disabled, onclick }) => Button({
text: 'Sign in',
disabled,
onclick,
});

Finalmente agregamos el callback que queremos registrar en el evento onclick en el componente SignIn.

src/pages/SignIn.js

import createElement from '../lib/createElement';
import SignInButton from '../components/SignInButton';
export default ({ store }) => createElement('div', {
className: 'sign-in',
children: [
SignInButton({
disabled: !!store.getState().loading,
onclick: () => store.setState({ loading: true }),
}),
],
});

Como vemos, nuestro callback simpemente va a invocar store.setState con el nuevo estado. Si vemos nuestra aplicación en el navegador, ahora el botón aparece como desabilitado después de hacer click!

Ya tenemos una manera de cerrar el ciclo y modificar el estado como resultado de interacción del usuario. Aprovechemos a actualizar nuestro diagrama para reflejar esto.

En el siguiente post de esta serie veremos como agregar operaciones asíncronas. En nuestro ejemplo vamos a enviar una petición a un servidor para hacer iniciar sesión (sign in), esperar la respuesta y hacer algo dependiendo del resultado.

Este post es parte de la serie “Arquitectura de interfaces web”. Si quieres continuar leyendo…

<< Parte 2: Vistas, componentes y renderizado | Parte 4: Async / App Container / Routing v1 (pronto 😉) >>

--

--