Arquitectura de interfaces web: Parte 3
Estado / HOCs / Store
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:
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
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 invoquesetState
).
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 😉) >>