Manejo de Estado con NGXS en Angular

Nicolás Avila
Angular Chile
Published in
15 min readNov 17, 2019

Este post está inspirado en una keynote del 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:

El cambio se gatilla en el componente E, mientras que los cambios en esa información desean visualizarse en el componente A

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?

El cambio se gatilla en el componente E. Un elemento externo a nuestro árbol de componentes, el estado, guarda este cambio el cual luego puede ser visualizado por el componente A

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:

  1. 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).
  2. 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:

  1. 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.
  2. 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.

https://ngxs.io

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:

  1. Store: Corresponde a un contenedor global que engloba las acciones, estado y selectores.
  2. 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).
  3. State: Corresponden a clases que definen el estado de nuestra aplicación. Podemos tener una o muchos (Ej: “pacientes”, “sistema”, “usuario”, etc).
  4. 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:

Esquema que muestra el flujo de información en NGXS

¿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:

Animación del comportamiento de nuestra aplicación

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.

Estructura de la aplicación

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?

  1. Importamos los módulos NgxsModule, NgxsReduxDevtoolsPluginModule, NgxsLoggerPluginModule. También importamos el archivo de environment.
  2. Agregamos al atributo imports del decorador del @NgModule cada uno de los módulos.
  3. 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 appcrearemos 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
Estructura del store para posts

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?

  1. Creamos el archivo posts.model.ts.
  2. 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?

  1. En el archivo post.actions.ts importamos la interfaz Post.
  2. Exportamos una clase llamada AddPost la cual posee dos miembros:
  • Un atributo static y readonly llamado type 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).
  1. Exportamos otra clase llamada RemovePost que posee dos miembros:
  • Un atributo static y readonly llamado type 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:

Partes del state de posts

¿Qué acabamos de hacer?

  • Importamos la dependencia principal de la librería @ngxs/store y el resto de los elementos exportados en los archivos posts.actions.ts y posts.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 archivo posts.actions.ts. Se definen las acciones add() y remove(). 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:

Descripción de add()
  1. Se utiliza el decorador @Action(), el cual acepta la clase AddPost desde el archivo posts.actions.ts.
  2. El primer argumento de la función es el StateContext. Este argumento se destructura para obtener las funciones getState() y patchState(). La función getState()permite obtener el valor del state actual, mientras que la función patchState() se utiliza para actualizar el contenido del state.
  3. El segundo argumento corresponde al action AddPostsque proviene desde el archivo posts.actions.ts. Este argumento se destructura para obtener el atributo payload presente en el constructos de la clase AddPosts.
  4. Se obtiene el valor del state con la función getState(), el valor se almacena en la variable state. Luego, utilizando la función patchstate() se agrega un nuevo valor al state utilizando el operador spread sobre la variable state más el valor de payload 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:

Descrcipción de remove()
  1. Se utiliza el decorador @Action(), el cual acepta la clase AddPost desde el archivo posts.actions.ts.
  2. El primer argumento de la función es el StateContext. Este argumento se destructura para obtener las funciones getState() y patchState(). La función getState()permite obtener el valor del state actual, mientras que la función patchState() se utiliza para actualizar el contenido del state.
  3. El segundo argumento corresponde al action AddPostsque proviene desde el archivo posts.actions.ts. Este argumento se destructura para obtener el atributo payload presente en el constructos de la clase AddPosts.
  4. Se utilizará la función patchState() para actualizar el contenido del state. Sobre el atributo posts se utiliza la función filter(), 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 del payload.

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?

  1. Agregamos Store y Select desde la dependencia principal @ngxs/store. Esto nos permitirá trabajar con el store para realizar dispatch de acciones y selectsdesde el state.
  2. Se declara la variable posts de tipo Observable<Posts>. Esta variable almacenará un Observable con Posts.
  3. Se inyecta la dependencia de Store para poder utilizar las funciones select() y dispatch(). En el constructor se realiza una selección al store de posts, particularmente al atributo posts (array de Posts).
  4. Se declara una función removePost() que acepta un argumento id de tipo string. Esta función realizará un dispatch al action RemovePost().

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?

  1. Importamos v4 de la dependencia uuid para poder generar luego identificadores únicos para nuestros posts.
  2. Agregamos Store desde la dependencia principal @ngxs/store. Esto nos permitirá trabajar con el store para realizar dispatch de acciones.
  3. Declaramos la variable text de tipo string. Esta variable utiliza two-way data binding con un <textarea> en el template del componente
  4. Declaramos la función addPost(). Esta función realiza un dispatch() 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 👏

--

--

Nicolás Avila
Angular Chile

MS Audiologist. Clinical Informatics Manager at Clínica Alemana de Santiago. Father, Dog Lover, 8-Bit Appreciator & Synth Geek