Manejo de Estado con NGXS en Angular
Este post está inspirado en una keynote del 1º Meetup de Angular Chile titulada: State Management con NGXS en Angular. Puedes acceder a más información de esta charla, slides y otras presentaciones en el siguiente link
Cuando comenzamos a escribir una aplicación utilizando Angular normalmente no nos preocupamos como los componentes comunican información entre ellos y este es un punto muy importante en el inicio de construcción de cualquier aplicación! (créanme, he tenido que sufrir por esto 😅). Recordemos que en Angular todos los componentes están sujetos a una dependencia jerárquica, donde un componente puede tener un componente padre y N componentes hijos. La forma en como la información se comunique entre los componentes determinará el comportamiento de nuestra aplicación 😄.
En este post hablaremos sobre el manejo de estado (state management), su importancia y cómo poder utilizar NGXS en nuestras aplicaciones con Angular siguiendo un ejemplo sencillo.
El código de este tutorial (más sus slides) puedes encontrarlo acá
¿Qué es el estado?
El estado corresponde a los datos e información que definen la condición de un objeto o sistema en un tiempo determinado.
Quizás la definición de arriba pueda sonar algo complicada, pero básicamente se refiere al valor de las variables de nuestros componentes (o aplicación) en un momento determinado. Cuando los valores cambian, el estado muta. Esto se ve reflejado en lo más importante: la UI de nuestra aplicación (la visibilidad de un botón, el color de un elemento o comportamientos complejos como el estado de autenticación en una aplicación).
A medida que las SPA (Single Page Application) comenzaron a crecer y se volvieron más complejas, se hizo necesario desarrollar técnicas que permitiesen comunicar la información entre los distintos elementos de nuestra aplicación de forma adecuada, sin el riesgo de cambiar esta información en el proceso (lo que se puede traducir en bugs 🐛).
¿Qué es el manejo de estado y porqué es importante?
Analicemos primero como ocurre la comunicación entre componentes. No es un tema trivial! ya que debemos tener en cuenta los mecanismos de detección de cambio del Framework. Veamos un ejemplo:
Imaginémonos que tenemos la siguiente estructura hipotética de una aplicación: El componente E, gatilla un cambio de estado, el cual necesitamos reflejar en el componente A. Quizás, mediante @Inputs()
y @Outputs()
pudiésemos lograr comunicar información entre componentes. Incluso quizás con un Service
. El problema radica cuando la aplicación comienza a crecer. El uso de estas técnicas se hace insuficiente, ya que se complejiza el mantenimiento en el tiempo y nada nos impide mutar la información en el camino.
¿No sería mejor que cada componente en la aplicación mirase a una fuente de verdad única encargada de manejar esta información siguiendo una convención de comunicación unidireccional?
La construcción de estas fuentes de verdad única externas a nuestro componentes corresponde a un patrón de diseño de nuestra aplicación. Es una decisión que agrega complejidad, pero que puede traer excelentes beneficios si se utiliza adecuadamente!.
El manejo de estado corresponde a elementos o librerías que permiten administrar el flujo de información (lectura, escritura, edición y eliminación) a estas fuentes de verdad únicas. Se basan en algunos principios que revisaremos a continuación:
- Inmutabilidad: El estado no puede ser modificado en forma directa. Deben existir pasos intermedios para poder cambiar el estado (como la creación de referencias a objetos).
- Flujo unidireccional de información: Quiere decir que no se debe utilizar data binding bidireccional con el estado. Siempre el flujo de información es hacia una sola dirección, utilizando elementos diseñados específicamente para las tareas de intercambio de información.
Un poco de historia: ¿De donde viene todo esto?
El manejo de estado es un tema que podemos encontrar desde hace mucho tiempo. Se hace mención de él en un libro de 1994 titulado “Design Patterns : Elements of Reusable Object-Oriented Software”, por Erich Gamma et. al.
El tópico se popularizó en el mundo del frontend luego de una charla del 2014 de Facebook, en su evento anual F8 titulada Hacker Way: Rethinking Web App Development at Facebook. Se presenta en esta charla un patrón de arquitectura llamado Flux, que refuerza el uso de un flujo unidireccional y React una librería para construir Single Page Applications.
Luego, el 2015, Dan Abramov dicta una charla en donde enfrenta ciertos problemas que lo llevan posteriormente junto con Andrew Clark a crear Redux. Redux es una librería para administrar el estado de aplicaciones. Introduce algunos elementos conceptuales, que abordaremos a continuación para luego entender de forma más sencilla los elementos y características de NGXS.
Elementos principales
Imaginémonos que el estado de nuestra aplicación puede representarse un objeto plano.
{
patients:[
{ id: 1, name: 'Rodrigo' waiting: false },
{ id: 2, name: 'Ana Inés' waiting: true },
]
}
Basado en esta estructura, definiremos algunos elementos que nos permitan interactuar con ella:
- Actions: Corresponden a elementos que indican que realizaremos una operación sobre nuestro “modelo”. Normalmente es un objeto plano que solamente describe lo que va a ocurrir.
- Reducer: No es posible mutar el estado directamente, por lo que necesitamos funciones especiales que permitan unir las Actions con los Stores. Esta es la función de los Reducers, los cuales toman el estado y una acción como argumentos y retornan el siguiente estado.
¿Qué es NGXS?
NGXS es una librería de manejo de estado y patrón para Angular escrita por Austin McDaniel (@amcdnl). Actúa como una fuente de verdad única para el estado de nuestra aplicación, utilizando una convención mínima con características de TypeScript como clases y decoradores.
Cuando analizamos la documentación de NGXS, nos encontramos con elementos comunes revisados en otras librerías de manejo de estado. A continuación revisaremos sus elementos más importantes:
- Store: Corresponde a un contenedor global que engloba las acciones, estado y selectores.
- Actions: Corresponden a clases que describen una acción a realizar. Poseen metadatos asociados (esos metadatos entregan una “descripción” que utilizaremos para saber que está ocurriendo con el estado).
- State: Corresponden a clases que definen el estado de nuestra aplicación. Podemos tener una o muchos (Ej: “pacientes”, “sistema”, “usuario”, etc).
- Selects: Selectores que toman una pequeña porción del estado para ser utilizado.
Todos estos componentes crean un flujo de información circular, que viaja desde un componente, el cual despacha una action al store. El store muta un estado en particular y la información viaja nuevamente al componente mediante un select para ser visualizada. A continuación un ejemplo:
¿Qué hace a NGXS especial?
Esta es una opinión muy personal ⚠️. Creo que NGXS es una excelente alternativa por los siguientes motivos:
- ⬇️ Boilerplate reducido: Para poder integrar NGXS no necesitamos mucho. Debemos instalar las dependencias, crear algunos archivos y estamos listos para poder utilizarlo 😄
- 🔧 Tooling: NGXS no solamente cuenta con la librería de manejo de estado, sino que poseen muchas herramientas que hacen más fácil aun su uso. Una de ellas (muy buena por cierto) es ngxs/cli, que nos permite generar el boilerplate necesario mediante una herramienta de línea de comando. Pueden revisar más de ello en la cuenta Github de NGXS https://github.com/ngxs.
- 🔌 Extensibilidad: Posee un excelente ecosistema de plugins. Algunos muy útiles como Logger y Reset
- 🔆 Buena comunicación con otras herramientas: Se hace muy sencillo de trabajar con algunas extensiones de navegador como Redux DevTools Extension.
- 👪 Comunidad: Posee una comunidad muy activa y dispuesta a ayudar.
Nuevamente: esta es una opinión muy personal. Creo que el resto de las alternativas disponibles son excelentes 🎉, pero NGXS es lo que más me acomodó en su momento y actualmente lo utilizo en gran parte de mis proyectos ❤️.
Usando NGXS en un ejemplo sencillo
Para este tutorial crearemos una aplicación sencilla llamada Angular Chile MessageBoard. Será una aplicación que tenga dos componentes: uno que me permita escribir una nueva entrada y otro que me permita listarlas y eliminarlas. A continuación una animación de lo que construiremos:
Primeros pasos
El primer paso es crear un nuevo proyecto con Angular CLI.
ng new ngxs-angular-chile-example
Si no lo tienes instalado en tu computador, te recomiendo que visites el siguiente vínculo: https://cli.angular.io
Esto generará una carpeta llamada ngxs-angular-chile-example con todo lo necesario para empezar a construir nuestra aplicación. El siguiente paso crear dos nuevos componentes. Ejecutaremos los siguientes comandos en nuestra terminal:
Creación de ListarPostComponent:
ng generate component listar-posts
Creación de NuevoPostComponent:
ng generate component nuevo-post
Se crearán dos componentes: ListarPostComponent y NuevoPostComponent. Estos componentes los modificaremos luego para poder utilizarlos con NGXS.
El siguiente paso es la integración de un framework css, porque queremos que todo se vea un poco más bonito 🌠. En nuestra terminal ejecutaremos lo siguiente:
npm install bulma --save
En este tutorial no abordaremos el método de integración del framework CSS. Si estás interesado en ello te invitamos a leer nuestro artículo “Integrar Frameworks CCS en Angular”
Debemos cerciorarnos que los dos componentes se encuentren en el atributo declarations
de nuestro archivo app.module.ts
(el resto de las partes del archivo app.module.ts
han sido omitidas)
import { NuevoPostsComponent } from './nuevo-post/posts.component';
import { ListarPostsComponent } from './listar-posts/listar-posts.component';@NgModule({
declarations: [
AppComponent,
NuevoPostsComponent,
ListarPostsComponent
],
});
Modificaremos el archivo app.component.html
para que muestre la siguiente información:
<div class="container">
<div class="columns">
<div class="column">
<h1>Angular Chile MessageBoard</h1>
</div>
</div>
<div class="columns">
<div class="column">
<h3>Nuevo Post</h3>
<app-nuevo-posts></app-nuevo-posts>
</div> <div class="column">
<h3>Lista de Posts</h3>
<app-listar-posts></app-listar-posts>
</div>
</div>
</div>
Integrando NGXS y sus herramientas
Una vez tengamos creada nuestra aplicación es necesario integrar NGXS. Para ello en nuestro terminal, situados en la carpeta de la aplicación ejecutaremos el siguiente comando:
npm install @ngxs/store --save
Luego, instalaremos algunas herramientas de desarrollo (como dependencias de desarrollo):
npm install -D @ngxs/logger-plugin @ngxs/devtools-plugin
¿Qué acabamos de instalar?
- @ngxs/store: Es la dependencia principal. Con esto podremos utilizar NGXS en nuestra aplicación de Angular.
- @ngxs/logger-plugin: Es una dependencia de desarrollo. Con esto podremos inspeccionar rápidamente en la consola como el estado va mutando a lo largo del tiempo.
- @ngxs/devtools-plugin: Es una dependencia de desarrollo. Nos permite utilizar la extensión Redux DevTools Extension con nuestra aplicación.
El siguiente paso es modificar el archivo app.module.ts
con lo siguiente (han sido omitidas las partes que no han sufrido cambios).
import { NgxsModule } from '@ngxs/store';
import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';
import { environment } from 'src/environments/environment';
@NgModule({
imports: [
BrowserModule,
FormsModule,
NgxsModule.forRoot([],
{ developmentMode: !environment.production }
),
NgxsReduxDevtoolsPluginModule.forRoot({
disabled: environment.production
}),
NgxsLoggerPluginModule.forRoot({
disabled: environment.production
})
],
})
¿Qué acabamos de hacer?
- Importamos los módulos
NgxsModule
,NgxsReduxDevtoolsPluginModule
,NgxsLoggerPluginModule
. También importamos el archivo deenvironment
. - Agregamos al atributo
imports
del decorador del@NgModule
cada uno de los módulos. - Hemos configurado los módulos con algunos objetos para que la información de debug solo esté presente cuando el build de la aplicación sea distinto a producción (no nos gustaría ver mensajes de debug en una aplicación de producción, verdad?).
Creando los archivos necesarios
Llegamos a la parte entretenida: crearemos nuestro primer state en nuestro store 🎉. Podemos hacerlo de dos modos:
- Utilizando @ngxs/cli
- Manualmente
En esta oportunidad prefiero que lo realicemos manualmente, ya que entenderemos el rol de cada uno de los archivos y cuales son sus partes más importantes. Dentro de la carpeta app
crearemos una nueva carpeta llamada store
, la que a su vez tendrá otra subcarpeta llamada posts
. En esta subcarpeta crearemos 3 nuevos archivos vacíos:
posts.model.ts
posts.actions.ts
posts.state.ts
El primer archivo que modificaremos será posts.model.ts
, donde agregaremos el siguiente contenido:
- posts.model.ts
export class PostsStateModel {
posts: Posts[];
}export interface Posts {
id: string;
text: string;
}
¿Qué acabamos de hacer?
- Creamos el archivo
posts.model.ts
. - Dentro de este archivo agregamos una interfaz llamada Posts. Esta interfaz posee solo dos atributos: id y text.
¿Qué es una
interface
? Una interfaz es un contrato sintáctico el que una entidad debe respetar. Por ejemplo: si para una respuesta de una API nosotros agregamos esta interfaz que acabamos de crear, esperaríamos que la respuesta tenga los atributos id y text en ella.
El siguiente paso será modificar el archivo post.actions.ts
, donde agregaremos lo siguiente:
import { Posts } from './posts.model';export class AddPost {
static readonly type = '[POSTS] Add';
constructor( public payload: Posts ) {}
}export class RemovePost {
static readonly type = '[POSTS] Remove';
constructor( public payload: string ) {}
}
¿Qué acabamos de hacer?
- En el archivo
post.actions.ts
importamos la interfazPost
. - Exportamos una clase llamada
AddPost
la cual posee dos miembros:
- Un atributo
static
yreadonly
llamadotype
que contiene una descripción de la función que cumple esa acción. La convención es utilizar "[STORE] + Descripción del comportamiento de la acción". - Un constructor, que acepta un argumento de tipo
Post
(acá utilizaremos la interfaz importada en el punto 1).
- Exportamos otra clase llamada
RemovePost
que posee dos miembros:
- Un atributo
static
yreadonly
llamadotype
que contiene una descripción de la función que cumple esa acción. - Un constructor, que acepta un argumento de tipo
string
. Acá pasaremos un string correspondiente al ID del post que eliminaremos posteriormente.
Finalmente, modificaremos el archivo post.state.ts
en donde agregaremos la siguiente información:
import { State, Action, StateContext, Selector } from '@ngxs/store';
import { PostsStateModel } from './posts.model';
import { AddPost, RemovePost } from './posts.actions';
@State({
name: 'posts',
defaults: {
posts: []
}
})
export class PostsState {
@Selector()
static getPosts(state: PostsStateModel) { return state.posts; }
// Añade un nuevo post al estado
@Action(AddPost)
add({ getState, patchState }: StateContext<PostsStateModel>, { payload }: AddPost) {
const state = getState();
patchState({
posts: [...state.posts, payload]
});
}
// Elimina un post del estado
@Action(RemovePost)
remove({ getState, patchState }: StateContext<PostsStateModel>, { payload }: RemovePost) {
patchState({
posts: getState().posts.filter(post => post.id !== payload)
});
}
}
Analizaremos las partes más importantes utilizando la siguiente imagen con anotaciones:
¿Qué acabamos de hacer?
- Importamos la dependencia principal de la librería
@ngxs/store
y el resto de los elementos exportados en los archivosposts.actions.ts
yposts.model.ts
. - Los states usando NGXS se definen utilizando el decorador
@State()
sobre una clase. Este decorador posee dos atributos importantes: - name: Corresponde al nombre del state.
- defaults: Contiene todos los valores por defecto del state. Corresponderá al estado inicial del state cuando la aplicación sea ejecutada.
- Se agregan las acciones que mutaran el estado (estos son los “reducers”): toman el estado que se desea modificar y una acción que describe que se realizará. Para poder definir estas acciones se utiliza el decorador
@Action()
que toma como parámetro una clase definida en el archivoposts.actions.ts
. Se definen las accionesadd()
yremove()
. A continuación revisaremos como funciona cada una:
Analizando el comportamiento de add()
Para analizar el comportamiento de add()
revisaremos cada uno de los elementos de la siguiente imagen:
- Se utiliza el decorador
@Action()
, el cual acepta la claseAddPost
desde el archivoposts.actions.ts
. - El primer argumento de la función es el
StateContext
. Este argumento se destructura para obtener las funcionesgetState()
ypatchState()
. La funcióngetState()
permite obtener el valor del state actual, mientras que la funciónpatchState()
se utiliza para actualizar el contenido del state. - El segundo argumento corresponde al action
AddPosts
que proviene desde el archivoposts.actions.ts
. Este argumento se destructura para obtener el atributopayload
presente en el constructos de la claseAddPosts
. - Se obtiene el valor del state con la función
getState()
, el valor se almacena en la variablestate
. Luego, utilizando la funciónpatchstate()
se agrega un nuevo valor al state utilizando el operador spread sobre la variablestate
más el valor depayload
que corresponde al nuevo post que se está intentando crear.
Analizando el comportamiento de remove()
Para analizar el comportamiento de remove)
revisaremos cada uno de los elementos de la siguiente imagen:
- Se utiliza el decorador
@Action()
, el cual acepta la claseAddPost
desde el archivoposts.actions.ts
. - El primer argumento de la función es el
StateContext
. Este argumento se destructura para obtener las funcionesgetState()
ypatchState()
. La funcióngetState()
permite obtener el valor del state actual, mientras que la funciónpatchState()
se utiliza para actualizar el contenido del state. - El segundo argumento corresponde al action
AddPosts
que proviene desde el archivoposts.actions.ts
. Este argumento se destructura para obtener el atributopayload
presente en el constructos de la claseAddPosts
. - Se utilizará la función
patchState()
para actualizar el contenido del state. Sobre el atributoposts
se utiliza la funciónfilter()
, la cual implementa una función que compara cada uno de los posts dentro del state. Esta función dejará todos los posts cuyo id sea distinto al id proveniente delpayload
.
Modificando el componente ListarPostsComponent
El siguiente paso será modificar el componente ListarPostsCompoment para que podamos mostrar y eliminar los posts desde el store. Modificaremos el archivo listar-posts.component.ts
con el siguiente contenido:
import { Component, OnInit } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { Observable } from 'rxjs';
import { Posts } from '../store/posts/posts.model';
import { RemovePost } from '../store/posts/posts.actions';@Component({
selector: 'app-listar-posts',
templateUrl: './listar-posts.component.html',
styleUrls: ['./listar-posts.component.scss']
})
export class ListarPostsComponent implements OnInit { public posts: Observable<Posts>; constructor(
private store: Store
) {
this.posts = this.store.select(state => state.posts.posts);
} ngOnInit() {
} public removePost(id) {
this.store.dispatch(new RemovePost(id));
}
}
¿Qué acabamos de hacer?
- Agregamos
Store
ySelect
desde la dependencia principal@ngxs/store
. Esto nos permitirá trabajar con el store para realizardispatch
de acciones yselects
desde el state. - Se declara la variable
posts
de tipoObservable<Posts>
. Esta variable almacenará un Observable conPosts
. - Se inyecta la dependencia de
Store
para poder utilizar las funcionesselect()
ydispatch()
. En el constructor se realiza una selección al store de posts, particularmente al atributoposts
(array dePosts
). - Se declara una función
removePost()
que acepta un argumento id de tipostring
. Esta función realizará un dispatch al actionRemovePost()
.
Luego, debemos modificar el archivo listar-posts.component.html
y agregar lo siguiente:
<ul>
<li *ngFor="let post of posts | async">
<div class="card">
<div class="card-content">
<p class="id"><small>ID: {{ post.id }}</small></p>
<p>{{ post.text }}</p>
<a class="button is-danger is-small" (click)="removePost(post.id)">Eliminar</a>
</div>
</div>
</li>
</ul><div class="notification is-danger" *ngIf="(posts | async).length == 0">
Sin Posts :(
</div>
Modificando el componente NuevoPostComponent
En el componente NuevoPostComponent debemos agregar una nueva dependencia a nuestra aplicación que nos permita generar identificadores únicos para nuestros posts. Para esto el paquete uuid nos puede ser de utilidad. Este paquete genera UUIDS basados en el estándar RFC4122. La instalación debemos hacerla por la terminal del sistema ejecutando el siguiente comando
npm install uuid --save
Luego, debemos modificar el archivo nuevo-post.component.ts
y agregar lo siguiente:
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngxs/store';
import { AddPost } from '../store/posts/posts.actions';
import { v4 as uuid } from 'uuid';@Component({
selector: 'app-nuevo-posts',
templateUrl: './nuevo-posts.component.html',
styleUrls: ['./nuevo-posts.component.scss']
})
export class NuevoPostsComponent implements OnInit { public text: string; constructor(
private store: Store
) { } ngOnInit() {
} public addPost() {
this.store.dispatch(new AddPost({ id: uuid(), text: this.text }));
this.text = '';
}
}
¿Qué acabamos de hacer?
- Importamos
v4
de la dependenciauuid
para poder generar luego identificadores únicos para nuestros posts. - Agregamos
Store
desde la dependencia principal@ngxs/store
. Esto nos permitirá trabajar con el store para realizardispatch
de acciones. - Declaramos la variable
text
de tipostring
. Esta variable utiliza two-way data binding con un<textarea>
en el template del componente - Declaramos la función
addPost()
. Esta función realiza undispatch()
para agregar un nuevo post a nuestro state.
Finalmente, modificaremos el archivo nuevo-posts.component.html
y agregaremos lo siguiente
<textarea class="textarea" [(ngModel)]="text"></textarea>
<button class="button" (click)="addPost()">Agregar</button>
Agregando el store al bootstrap de la aplicación
Nada de esto funcionará adecuadamente si no agregamos los states al bootstrap de nuestra aplicación. Para ello debemos modificar el archivo app.module.ts
y agregar el state PostsState
como argumento a la función forRoot()
de la importación del módulo NgxsModule (el resto del contenido ha sido omitido):
import { PostsState } from './store/posts/posts.state';@NgModule({
imports: [
NgxsModule.forRoot([
PostsState
],
],
})
¿Cuándo utilizar una librería de manejo de estado con NGXS?
Agregar una librería de manejo de estado siempre agregará complejidad adicional a nuestra base de código. Es importante que evaluemos tempranamente la verdadera necesidad de contar con esta característica en nuestras aplicaciones construidas con Angular.
A continuación les entrego una lista de cuando debiésemos considerarlo:
- Cuando nuestra aplicación es mediana a grande, es muy probable que necesitemos utilizar información generada por componentes en otros lados de la aplicación.
- Cuando necesitemos reutilizar información en distintos puntos de la aplicación.
- Cuando necesitamos comunicar cambio de estado en una rama distal en nuestro árbol de dependencia de componentes a uno más medial.
¿Qué otras alternativas hay?
- NGRX: Es la alternativa más popular actualmente en Angular. Puedes obtener más información en su página oficial.
- Akita: Es una librería escrita por Netanel Basal. Puedes obtener más información en su página oficial
Referencias y material
Espero que hayas disfrutado esta publicación tanto como disfruté yo redactándola 😃. Recuerden que si tienen alguna duda pueden contactarme a través de mi Twitter @nicoavila_a. Si te gustó la publicación, compártela y deja algunos claps 👏