Redux en LitElement

Ya hace un tiempo que realizamos una primera aproximación teórica a Redux, una librería específica para el control del estado, útil para aplicaciones desarrolladas bajo cualquier framework frontend. Resulta especialmente interesante porque encaja muy bien con el modelo de desarrollo de LitElement, aunque no sea necesario su uso en las aplicaciones basadas en Web Components, así que esto es algo que abordamos en este artículo.

Lo primero de todo, tenemos que ser conscientes de que Redux nos aporta la posibilidad de emplear un contenedor global para el estado de la aplicación, implementando un flujo de datos unidireccional de la información. Debemos plantearnos si esto resulta adecuado para una aplicación basada en LitElement. Tendremos en cuenta que contamos con el patrón mediador para la arquitectura de datos en una aplicación LitElement. Esto podría ser suficiente, pero en caso de necesitar más recursos, podemos echar mano de Redux.

Antes de implementar Redux, te recomendamos revisar nuestro artículo sobre su funcionamiento, en el que explicamos las acciones y otros conceptos de los que hablamos en este post.

Implementación de Redux

Para ir directamente a la parte práctica, vamos a mostraros cómo implementar Redux en una aplicación LitElement. Para ello, partiremos de la base de que la aplicación estará creada, utilizando como gestor de dependencias npm.

Instalar dependencias (Redux y PWA-Helpers)

Vamos a instalar Redux a través del comando:

npm i redux

El siguiente paso será instalar otra herramienta que se llama PWA-Helpers:

npm i pwa-helpers

Es una librería muy interesante como complemento para desarrollar Progressive Web Apps, ya que nos brinda varios mixins y funciones muy prácticas para desarrollar las aplicaciones progresivas. Es el caso del mixin connect, muy efectivo para conectar el store de Redux con Web Components.

Crear el store de Redux

Para poder implementar Redux, debemos crear el store, del lado del código. Hablamos del contenedor global de los datos manejados por la aplicación, el estado. Podemos decir que el store en Redux es el lugar donde se almacenan los datos que debe usar la aplicación.

Antes de crear un store, es interesante disponer de un reducer, que es una función que recibe acciones y realiza la manipulación del estado, dependiendo de lo que estas acciones le han solicitado.

Si tenemos ya el reducer listo, podemos crear el store con el siguiente código del  módulo redux /store.js:

// Aquí hacemos la importación de la función createStore, de Redux
import { createStore } from 'redux';
// hacemos lo mismo con reducer, que veremos a continuación

import { reducer } from './reducers/reducer';
// con la función createStore() creamos el store, enviando el reducer como parámetro.
// exportamos el store para que otros lo puedan importar fuera de este módulo.
export const store = createStore(reducer);

Crear el reducer

El reducer es una función que recibe un estado y una acción. Disponer del reducer hará que cuando el store tenga que realizar alguna acción, llame al reducer para indicar el estado actual y qué acción debe ejecutar.

Lo habitual es implementar el reducer con un switch, utilizando una rama para cada tipo de acción a procesar. Este es el código del módulo redux/reducer/reducer.js:

// fijamos el estado inicial. Si no lo tiene, cogerá los valores cuando arranque la aplicación.

const estadoInicial = {
counter: 0,
appName: 'MyApp'
}

// creamos y exportamos la función del reducer

export const reducer = (state = estadoInicial, action) => {
switch(action.type) {
case "INCREMENT":
return {
...state,
counter: state.counter + 1

      }
case "DECREMENT":
return {
...state,
counter: state.counter – 1
}

    case "CHANGE_APP_NAME":
return {
...state,
appName: action.name
}
default:
return state;
}
}

Si te fijas en el código, el código de estado inicial de nuestra aplicación contiene solamente dos propiedades:

{
counter: 0,
appName: 'MyApp'
}

Más adelante, en la función correspondiente al reducer, se observan diferentes clases de acciones, como INCREMENT, DECREMENT y CHANGE_APP_NAME. Cada una de las acciones devuelve una nueva copia del estado, cambiando las propiedades en caso necesario.

Tal y como ocurre con todos los reducer, en caso de no entregarse una acción, o de no implementarse las que se han solicitado, lo que devolverá es el estado actual. Es la denominada rama default del switch.

Crear las acciones (Action creators)

Cada vez que queramos cambiar el estado de la aplicación, debemos disparar una acción mediante el store. Esta acción es un objeto plano que debe indicar qué tipo de acción y los datos que se necesitan para ejecutarla. La mejor manera de conseguirlo es mantener centralizadas creaciones de las acciones. Esto se consigue a través de los denominados action creators, unas funciones que facilitan la definición de cada acción. Colocaremos el código de los action creators en un módulo llamado redux/actions/actions.js:

// acción para incrementar el contador

export const incrementarContador = () => {
return {
type: 'INCREMENT'
}
}

// acción para decrementar un contador

export const decrementarContador = () => {
return {
type: 'DECREMENT'
}
}

// acción para cambiar el nombre de la aplicación 

export const cambiarAppName = (name) => {
return {
type: 'CHANGE_APP_NAME',
name
}
}

Tras esta configuración, deberíamos tener una estructura de carpetas como esta:

Conectar componentes de LitElement con Redux

En este punto veremos cómo crear componentes que se conectarán a store para poder recibir todos los cambios de estado de la aplicación, además de enviar acciones para cambiar las propiedades del estado. Aquí emplearemos el mixin connect de la librería pwa-helpers que hemos instalado antes como dependencia.

Pero antes, veamos los dos tipos de componentes que puede haber en una aplicación:

  • Componentes conectados al store: están conectados directamente al contenedor del estado, por lo que si el estado se modifica, serán notificados con los nuevos valores del store.
  • Componentes desconectados del store: no reciben ninguna notificación cuando cambia algún dato del store.

Aunque un componente esté desconectado del store, puede utilizar los datos contenidos en éste. Puede darse el caso de que este componente tenga un padre conectado al store que le pase los datos necesarios mediante data-binging.

Mixin connect

Gracias al mixin connect podemos desarrollar componentes conectados al store. Primero deberemos importarlo para luego emplearlo para declarar la clase del componente.

import { connect } from 'pwa-helpers';

class ReduxLitelementApp extends connect(store)(LitElement) {
// ...
}

En este código podemos apreciar que debemos enviarle el store como parámetro para poder utilizarlo. El mixin que debemos aplicar es el resultado de ejecutar connect() enviando el store al que nos conectamos.

Este mixin avisa al componente cuando existen cambios en el store. Para poder hacerlo, debemos escribir en el componente un método conocido como stateChanged, que recibe el nuevo estado cuando cambia.

stateChanged(state) {
console.log('statechanged', state);
this.appName = state.appName;
this.counter = state.counter
}

Gracias a esto, cada vez que cambie el estado, se ejecutará el método stateChanged(). En el método se almacenarán los datos que necesite manejar el componente, dentro de las propiedades del componente.

Componente raíz, conectado al store

A continuación, veremos cómo se conecta al store un componente. En este caso, estudiaremos cómo lo hace el componente raíz de la aplicación, que suele necesitar conocer varios datos del store.

Primeramente, importaremos una serie de cosas para usarlas cuando implementemos Redux. Veamos  el código fuente:

import { LitElement, html } from 'lit-element';
import { connect } from 'pwa-helpers';
import { store } from './redux/store';
import './counter-user-interface';
import './show-counter';

class ReduxLitelementApp extends connect(store)(LitElement) {
static get properties() {
return {
appName: { type: String },
counter: { type: Number },
};
}
render() {
return html`
<h1>${this.appName}</h1>
<show-counter counter="${this.counter}"></show-counter>
<counter-user-interface></counter-user-interface>
`;
}
stateChanged(state) {
console.log('statechanged', state);
this.appName = state.appName;
this.counter = state.counter
}
}

customElements.define('redux-litelement-app', ReduxLitelementApp);

Gracias a este código, el componente recibe el estado cada vez que se cambie el store de manera muy clara para nosotros. Los datos del estado se pueden utilizar en la plantilla. Así, si se modifican también cambiará la vista del componente. Estos datos se pueden pasar a componentes hijos en caso necesario, como ocurre con el binding de show-counter.

Este mismo esquema puede emplearse para crear componentes conectados al store, en cualquier punto del árbol de componentes de la aplicación y en caso de usar connect mixin, puede recibir todos los cambios del store.

Cómo disparar acciones

Para conseguir que un dato del store se modifique, es necesario disparar acciones. Aunque no es necesario que el componente esté conectado al store cuando queremos disparar acciones (no necesita implementar el mixin connect ), sí necesitamos  importar el propio store para despachar las acciones.

Podemos echar mano de los creadores de acciones del módulo de actions.js, ya que nos brindan unas funciones ayudantes y así crear las acciones con la sintaxis exacta necesaria. A continuación veremos el código de un componente capaz de disparar acciones:

import { LitElement, html } from 'lit-element';
import { store } from './redux/store';
import { incrementarContador, decrementarContador } from './redux/actions/actions'
export class CounterUserInterface extends LitElement {
render() {
return html`
<hr>
<button @click="${this.incrementar}">Incrementar</button>
<button @click="${this.decrementar}">Decrementar</button>
`;
}
incrementar() {
store.dispatch(incrementarContador());
}
decrementar() {
store.dispatch(decrementarContador());
}
}
customElements.define('counter-user-interface', CounterUserInterface);

Aquí se puede observar que necesitamos importar el store y los creadores de acciones del módulo de actions.js.

Posteriormente, utilizamos los creadores de acciones para despacharlas con store.dispatch(), como ocurre en las aplicaciones Redux, enviando la acción que corresponda.

Solucionar el error «Process is not defined»

Es posible que,al ejecutar la aplicación salte el error Uncaught ReferenceError: process is not defined. Esto se debe a que la dependencia de Redux toma como base que existe como variable de entorno una propiedad process., tal y como se detalla en el GitHub de Redux. Afortunadamente, puede solucionarse de manera sencilla con el siguiente código en el archivo index.html de nuestra aplicación:

<script>
window.process = { env: { NODE_ENV: 'production' } };
</script>

El PWA-Starter-Kit que usa Redux nos aporta esta solución, junto a otras recomendaciones para implementar otras cuestiones más avanzadas de Redux.

Con esto, ya  hemos visto  las nociones básicas para comenzar a comprender Redux en el marco de aplicaciones con LitElement.