NgRx es uno de los sistemas de gestión de estados más populares dentro de todo el ecosistema Angular. Pero, ¿qué es realmente un sistema de gestión de estados y por qué necesito uno dentro de mi aplicación?
Empecemos con una rápida explicación
Estado
Conjunto de datos, que están siendo utilizados dentro de nuestra aplicación
Sistema de gestión
Conjunto de herramientas centralizadas para simplificar / organizar / estructurar los datos utilizados por nuestra aplicación.
Por definición, los datos del sistema de gestión de estado tienen un alcance global, lo que significa que se pueden utilizar / acceder desde cualquier lugar dentro de nuestra aplicación. A diferencia de Angular Service, no se puede aplicar a una parte o característica específica de la aplicación.
NgRx ofrece adicionalmente la librería @ngrx/component-store
, específicamente para proporcionar la funcionalidad de scoping
. Component Store
puede ser cargado perezosamente y está ligado al ciclo de vida del componente (inicializado con la creación del componente y destruido al mismo tiempo) será cubierto en otro artículo.
Pero estamos aquí para centrarnos más en un enfoque global. La librería NgRx proporciona una implementación Redux de la Arquitectura Flux, lo que significa flujo unidireccional y predictibilidad.
¡No todos los conjuntos de datos necesitan el uso del sistema de gestión de estados! ¡No tienes que poner todo en el almacén NgRx! Si tus datos necesitan ser compartidos / reutilizados dentro de diferentes características de la aplicación / componentes no relacionados o contiene diccionarios ¡tiene sentido usar NgRx! De lo contrario, es posible que no lo necesite.
La arquitectura Flux proporciona una clara separación de las preocupaciones, lo que hace que nuestro código sea limpio, pero también hay compensaciones como la necesidad de implementar algún código repetitivo. Se necesita un poco más para configurar todo y ponerlo en marcha, pero vale la pena cada segundo de su tiempo. Y cuanto más tiempo lo utilices, más fácilmente aprovecharás sus características.
Si tu quieres aprender acerca de NgRX puedes ver el siguiente video
Estado: Representa un objeto JSON plano que es una única fuente de verdad de los datos de la aplicación de alcance global.
Selectores: Funciones memorizadas que permiten tomar una parte específica del estado y escuchar/suscribirse a sus cambios.
Reductores: El único lugar donde se actualiza directamente el objeto de estado de forma inmutable. Garantiza actualizaciones predecibles. Podrías usar un adaptador de la librería @ngrx/entity
para asegurar algunas de las operaciones inmutables más genéricas.
Efectos: Clases (¡ahora incluso funciones!) para manejar operaciones asíncronas, como la obtención de datos. Este es el lugar donde podemos escuchar el envío de acciones específicas. En la mayoría de los casos, los efectos devuelven una acción, pero no es esencial.
Acciones: Son operaciones/comandos específicos que se envían, como actualizaciones de datos (operaciones CRUD). Esta es la única manera de actualizar el estado de la aplicación NgRx.
Adicionalmente, específicamente en aplicaciones Angular, tiene sentido aplicar la técnica de Servicios de Fachada una clase que implementa el patrón de diseño de fachada está siendo usada como la única posibilidad de acceder a datos/ disparar acciones desde el almacén.
Además, echa un vistazo a estas herramientas Angular dev para simplificar el desarrollo Angular en 2023.
Ejemplo de uso:
Cuando se necesita acceder a los datos de la tienda, tenemos que inyectar a través de inyección de dependencia, Facade Service en el componente que necesita utilizar los datos / métodos / atributos de allí. No se permite la inyección directa de dependencias del almacén en el componente.
Esto es crucial en el caso de proporcionar una única implementación, que puede ser reutilizada en otros lugares (diferentes áreas de la aplicación).
Para empezar, ejecute el siguiente comando:
ng add @ngrx/store@latest
La tienda NgRx se añadirá inicialmente a su archivo package.json
, también se instalará el paquete eslint
adicional, si está utilizando eslint
en tu aplicación. Si navegas a tu archivo main.ts
, te darás cuenta de que provideStore()
se ha añadido automáticamente a tu matriz de proveedores.
A continuación, tenemos que instalar algunas bibliotecas adicionales NgRx con el siguiente comando:
npm install @ngrx/{effects,entity,store-devtools} --save
Estamos planeando configurar:
@ngrx/store-devtools
, con el fin de conectar nuestra aplicación con las herramientas de desarrollo del navegador y tener esta agradable experiencia de observar nuestros cambios de estado / activación de acciones, entre otros.
@ngrx/effects library es necesaria, ya que realizaremos operaciones asíncronas.
La librería @ngrx/entity
nos ayudará a gestionar nuestros datos de forma escalable, utilizando un enfoque tipo diccionario, donde nuestras entidades se almacenan dentro de Record<id, entity>
donde la clave es el identificador único de nuestra entidad y el valor es la propia entidad.
De esta manera, incluso si manejamos cientos de miles de entidades, nuestro mecanismo de almacenamiento tendrá el mismo rendimiento que con sólo un par de ellas (algoritmo O grande). Qué atrevido, ¿verdad?
Ahora, tenemos que crear nuestras acciones, reductores y efectos, pero antes de hacer eso vamos a centrarnos en la estructura real de nuestro almacén. Yo prefiero un enfoque basado en características, donde cada tipo de entidad se maneja por separado. Esta técnica asegurará una buena separación, mantenibilidad y un enfoque atómico.
Nuestra librería @ngrx/store-devtools
ha sido obtenida con éxito, por lo tanto podemos conectarla a nuestra aplicación independiente como se muestra a continuación:
import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
if (environment.production) {
enableProdMode();
}
bootstrapApplication(AppComponent, {
providers: [
provideStore(),
provideStoreDevtools({
maxAge: 25, // Retains last 25 states
logOnly: !isDevMode(), // Restrict extension to log-only mode
autoPause: true, // Pauses recording actions and state changes when the extension window is not open
trace: false, // If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
}),
],
}).catch(err => console.error(err));
Para poder utilizar completamente Store Devtools, necesitamos instalar el plugin del navegador. Como utilizo el navegador Chrome, será Redux Devtools que es accesible aquí.
Prefiero crear una carpeta dedicada para mantener toda la implementación de la tienda en un solo lugar con la siguiente estructura:
- store
|-feature-store-1
|-feature-store-2
|-feature-store-2
| |-index.ts
| |-feature-store-1.actions.ts
| |-feature-store-1.effects.ts
| |-feature-store-1.facade.ts
| |-feature-store-1.reducers.ts
| |-feature-store-1.selectors.ts
| |-feature-store-1.state.ts
| |-... // do not forget about unit tests files with .spec.ts! :)
|-index.ts
En mi caso, he creado mensajes característica carpeta dentro de la tienda y mi estructura se parece a la siguiente:
- store
|-messages
| |-index.ts
| |-messages.actions.ts
| |-messages.effects.ts
| |-messages.facade.ts
| |-messages.reducers.ts
| |-messages.selectors.ts
| |-messages.state.ts
|-index.ts
Ahora vamos a llenar los archivos con alguna configuración básica.
Así es como se ve mi archivo messages.state.ts
:
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { Message } from '../../messenger';
export interface MessagesState extends EntityState<Message> {
loading: [];
}
export const selectId = ({ id }: Message) => id;
export const sortComparer = (a: Message, b: Message): number =>
a.publishDate.toString().localeCompare(b.publishDate.toString());
export const adapter: EntityAdapter<Message> = createEntityAdapter(
{ selectId, sortComparer }
);
export const initialState: MessagesState = adapter.getInitialState(
{ loading: [] }
);
Estoy utilizando la biblioteca @ngrx/entity
para gestionar convenientemente mis datos. Dentro de mi definición de estado característica extiendo EntityState
genérico proporcionado la biblioteca con mi propia interfaz de datos Mensaje. Luego estoy declarando
Función selectId
para decirle al adaptador, cómo crear un identificador único para el valor de mi objeto entidad definición de la función sortComparer
(comparer no es necesario, no tienes que especificarlo).
Por último, creo mi instancia de adaptador y initialState
, que se pasará al reductor como punto de partida. Gracias al adaptador mi initialState
tendrá este aspecto dentro de las herramientas de desarrollo del navegador:
{
messages: {
ids: [],
entities: {},
loading: [],
},
}
No hemos añadido ids ni atributos de entidades, esta es la magia del adaptador :-). Así, cuando añadimos una nueva entidad despachando una acción, nuestro estado tendrá el siguiente aspecto:
{
messages: {
ids: ['1'],
entities: {
'1': {
... // my Message entity attributes
}
},
loading: [],
},
}
Añadamos ahora acciones al archivo messages.actions.ts
:
import { createAction, props } from '@ngrx/store';
import { Message } from '../../messenger';
export const messagesKey = '[Messages]';
export const addMessage = createAction(
`${messagesKey} Add Message`,
props<{ message: Message }>()
);
export const deleteMessage = createAction(
`${messagesKey} Delete Message`,
props<{ id: string }>()
);
La librería NgRx nos permite crear ActionGroups
, ¡pero cubriré este tema en otro artículo!
A continuación, en nuestra agenda está el archivo messages.reducers.ts
. Así es como debería verse:
import { ActionReducer, createReducer, on } from '@ngrx/store';
import { adapter, initialState, MessagesState } from './messages.state';
import { addMessage, deleteMessage } from './messages.actions';
export const messagesReducers: ActionReducer<MessagesState> = createReducer(
initialState,
on(addMessage, (state: MessagesState, { message }) =>
adapter.addOne(message, state)),
on(deleteMessage, (state: MessagesState, { id }) =>
adapter.removeOne(id, state))
);
Creamos messagesReducer
con la función createReducer
de la librería @ngrx/store.
Pasamos initialState
declarado en messages.state.ts
y usamos un adaptador ya creado en cada caso.
La librería NgRx ofrece la creación de funciones con una experiencia muy similar a React Redux Toolkit o Vuex.
Por otro lado, organiza tu código NgRx basado en módulos de características. Cada módulo Feature debería encapsular acciones relacionadas, reductores, efectos, selectores y modelos de estado.
A continuación, puedes empaquetarlos de forma independiente, auto-documentarlos, compartirlos y reutilizarlos usando Bit. Este enfoque mantiene tu código organizado y es más fácil de entender y mantener.
Ahora, es el momento de añadir nuestras queridas exportaciones de barril al archivo index.ts
dentro de nuestra carpeta de mensajes de esta manera:
// file location: store/messages/index.ts
export * from './messages.actions';
export * from './messages.reducers';
export * from './messages.state';
Todo lo que tenemos que hacer ahora es conectar nuestro almacén de mensajes al proveedor del almacén como se muestra a continuación:
import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { messagesReducers } from './app/store/messages';
if (environment.production) {
enableProdMode();
}
bootstrapApplication(AppComponent, {
providers: [
provideStore({ messages: messagesReducers }), // <-- this is the place! :-)
provideStoreDevtools({
maxAge: 25, // Retains last 25 states
logOnly: !isDevMode(), // Restrict extension to log-only mode
autoPause: true, // Pauses recording actions and state changes when the extension window is not open
trace: false, // If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
}),
],
}).catch(err => console.error(err));
En este punto, si queremos probar nuestro logro, estamos perfectamente bien para hacer algunas comprobaciones de cordura, mediante la inyección de la tienda a nuestro componente aleatorio y el envío de una acción como esta:
import { Component, inject, OnInit } from '@angular/core';
import { MessagesService } from '../../services';
import { Message } from '../../models';
import { Store } from '@ngrx/store';
import { addMessage } from '../../../store/messages';
@Component({
selector: 'app-messenger',
templateUrl: './messenger.component.html',
styleUrls: ['./messenger.component.scss'],
standalone: true,
})
export class MessengerComponent implements OnInit {
private readonly store: Store = inject(Store);
addMessage(): void {
const message: Message = { /* message object with id attribute */ };
this.store.dispatch(addMessage({ message: { content } }));
}
ngOnInit(): void {
this.addMessage();
}
Dentro de su Chrome (u otro navegador de su elección) Redux herramientas de desarrollo, usted será capaz de ver la acción enviada / su carga útil / diff / estado más reciente, al igual que a continuación:
También podemos añadir la interfaz principal del estado de la aplicación, que puede llamarse AppState
, el lugar ideal para ello parece ser dentro del archivo index.ts
ubicado directamente dentro de la carpeta store:
// file location: store/index.ts
import { MessagesState } from './messages';
export interface AppState {
messages?: MessagesState;
}
Ahora, una vez que tenemos los datos reales dentro de nuestra tienda, podemos crear selectores dentro de nuestro archivo messages.selectors.ts
(¡no olvide añadir la exportación de barriles a store/messages/index.ts respectivo!)
import { AppState } from '../index';
import { createFeatureSelector, createSelector, MemoizedSelector } from '@ngrx/store';
import { MessagesState } from './messages.state';
import { Message } from '../../messenger';
export const selectMessagesFeature: MemoizedSelector<AppState, MessagesState> =
createFeatureSelector<MessagesState>('messages');
export const selectMessages: MemoizedSelector<AppState, Message[]> =
createSelector(
selectMessagesFeature,
({ entities }: MessagesState): Message[] =>
Object.values(entities) as Message[]
);
Ya casi estamos, pero queremos ser pro y seguir los principios de programación DRY. No queremos inyectar store cada vez que necesitemos acceder a nuestros datos de estado. ¡Introduciremos Facade
específica para nuestro MessagesState
! ¡Recuerda añadir facade
import al archivo store/messages/index.ts
!
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Message } from '../../messenger';
import { addMessage } from './messages.actions';
import { selectMessages } from './messages.selectors';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class MessagesFacade {
private readonly store: Store = inject(Store);
readonly messages$: Observable<Message[]> = this.store.select(selectMessages);
addMessage(message: Message): void {
this.store.dispatch(addMessage({ message }));
}
}
El siguiente paso es reemplazar nuestro uso de prueba con MessagesFacade
real:
import { Component, inject, OnInit } from '@angular/core';
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { MessagesService } from '../../services';
import { Message } from '../../models';
import { MessagesFacade } from '../../../store/messages';
@Component({
selector: 'app-messenger',
templateUrl: './messenger.component.html',
styleUrls: ['./messenger.component.scss'],
standalone: true,
})
export class MessengerComponent implements OnInit {
private readonly messagesFacade: MessagesFacade = inject(MessagesFacade);
addMessage(): void {
const message: Message = { /* message object with id attribute */ };
this.messagesFacade.addMessage(message));
}
ngOnInit(): void {
this.addMessage();
}
Como resultado, no hay referencia al almacén directamente, todo lo relacionado con MessagesState
está siendo navegado a través de MessagesFacade
. ¡Ahora es perfectamente posible añadir nuevos mensajes a nuestro store
desde cualquier parte de nuestra aplicación!
Simplemente no olvides añadirlo al fichero store/messages/index.ts
:
// file location: store/messages/index.ts
export * from './messages.actions';
export * from './messages.reducers';
export * from './messages.state';
export * from './messages.selectors';
export * from './messages.facade';
Hasta ahora, estamos manejando nuestras operaciones síncronas con nuestra configuración de tienda NgRx, ¡queda un poco que está relacionado con las operaciones asíncronas!
Si queremos persistir nuestros mensajes en algún lugar dentro de la DB, necesitamos llamar al endpoint respectivo y pasarlo a nuestra implementación de Backend. Como esta operación lleva tiempo, se realiza de forma asíncrona (no sabemos si la respuesta llegará en algún momento y, si lo hace, no sabemos cuándo).
Aquí es donde entra en juego la librería @ngrx/effects. Configuremos los efectos dentro de nuestra implementación de gestión de estados basada en componentes independientes.
Necesitamos añadir dos acciones adicionales a nuestro archivo messages.actions.ts
.
Este es el aspecto que debería tener el archivo:
import { createAction, props } from '@ngrx/store';
import { Message } from '../../messenger';
export const messagesKey = '[Messages]';
export const addMessage = createAction(
`${messagesKey} Add Message`,
props<{ message: Message }>()
);
export const deleteMessage = createAction(
`${messagesKey} Delete Message`,
props<{ id: string }>()
);
export const deleteMessageSuccess = createAction(
`${messagesKey} Delete Message Success`
);
export const deleteMessageError = createAction(
`${messagesKey} Delete Message Error`
);
Además, se necesita algún ApiService
para mantener abstraída la capa de comunicación de la API (../../shared/services/messages-api.service.ts)
:
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class MessagesApiService {
private readonly http: HttpClient = inject(HttpClient);
deleteMessage(id: string): Observable<void> {
return this.http.delete<void>(`/${id}`);
}
}
Hay dos opciones para declarar efectos, puedes usar un enfoque de clase o funcional. Como el enfoque funcional es relativamente nuevo, lo utilizaremos en nuestro ejemplo (archivo messages.effects.ts
):
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { deleteMessage, deleteMessageError, deleteMessageSuccess } from './messages.actions';
import { MessagesApiService } from '../../shared/services/messages-api.service';
import { catchError, map, mergeMap } from 'rxjs';
export const deleteMessage$ = createEffect(
(actions$: Actions = inject(Actions), messagesApiService: MessagesApiService = inject(MessagesApiService)) => {
return actions$.pipe(
ofType(deleteMessage),
mergeMap(({ id }) =>
messagesApiService.deleteMessage(id).pipe(
map(() => deleteMessageSuccess()),
catchError(() => [deleteMessageError()])
)
)
);
},
{ functional: true }
);
Para empezar, usamos la función createEffect
de la librería @ngrx/effects
y le pasamos dos argumentos, uno es un efecto real que queremos crear y el segundo es un objeto de configuración. Como estamos siguiendo un enfoque funcional, nuestra configuración contendrá una bandera funcional establecida a true.
Necesitamos inyectar actions$
y nuestro ApiService
como argumentos a nuestro efecto, exactamente como en el siguiente ejemplo. Esta es la forma preferida ya que este efecto es definitivamente más fácil de probar.
Escuchamos la llamada al ApiService
y reaccionamos al éxito o al fracaso con acciones específicas.
Como estamos utilizando un enfoque funcional, la exportación de efectos tendría que ser ligeramente diferente. Sólo tienes que añadir esta parte a store/messages/index.ts
:
export * as messagesEffects from './messages.effects';
Sólo falta añadir un envío de acción dentro de nuestra MessagesFacade
y estaremos listos para probar nuestro efecto:
deleteOne(id: string): void {
this.store.dispatch(deleteMessage({ id }));
}
Ahora ya puedes llamar a deleteOne message desde cualquier parte de tu aplicación.
¡Listo! ¡Ya hemos terminado! La implementación de NgRx nunca ha sido tan sencilla.