Cómo gestionar el estado de Angular en sus componentes

Actualizado el 3 de diciembre de 2020

Gracias @AlexOkrushko y @Nartc1410 por el gran comentario.

Gestionar el estado de tu aplicación Angular siempre ha sido un reto.

En este tutorial, explicaré cómo gestionar el estado de tu componente con @ngrx/component-store. Podrás hacerlo de una forma más organizada y minimizando los bugs e inconsistencias de la UI.

Tabla de contenido

Requisitos previos

  1. Conocimientos básicos de Angular
  2. Conocimientos básicos de RXJS
  3. Cuenta con angular-cli instalado o Stackblitz

¿Qué vamos a construir?

Una aplicación para gestionar el aparcamiento de coches y tendrá las siguientes partes:

  1. store.service: Donde gestionaremos todo nuestro estado y toda la lógica de la UI
  2. parking-lot.service: Para comunicarnos con el backend (para la demo)
  3. app.component: Componente padre. Consumimos el estado y añadimos coches al parking
  4. car-list.component: Para mostrar la lista de coches aparcados.

Si quieres, puedes saltar al código fuente, sin compromiso 🤓 o directamente al tutorial.

¿Qué es el "estado"?

Es la representación de su UI mediante un objeto, y podríamos cambiarlo de diferentes maneras, por ejemplo:

Ejemplo:

state = {
    cars: [],
    loading: true,
    error: '',
}
  1. Lista de coches en el aparcamiento: cars: []
  2. Para cambiar la UI de nuestra app mientras se realiza una operación que tarda en resolverse, por ejemplo, una petición de red: loading: true.
  3. Para mostrar los errores que puedan ocurrir durante la ejecución de la aplicación: error: ''

Casi todos los componentes tienen un estado. Los manejamos indirectamente usando propiedades y cambiándolas durante su ciclo de vida.

En resumen un estado es:

  1. Es un objeto que representa la vista de tu componente
  2. No son los datos que vienen del servidor, de hecho, esto puede ser parte de ellos
  3. Puede tener tantos niveles como necesites
  4. Es inmutable. Cuando necesitas actualizar una propiedad, no la cambias directamente sino que creas un nuevo objeto con la propiedad modificada.

No todas las aplicaciones Angular necesitan NgRx o NGSX

La mayoría de las aplicaciones Angular no necesitan un sistema de gestión de estado completo. Es mejor gestionar el estado a nivel de componente antes de implementar una solución más compleja a nivel de app como NgRx o NGSX.

No todas las aplicaciones Angular necesitan NgRx o NGSX

Tweet Link

El problema

Si tienes un componente inteligente con varios componentes hijos, probablemente tengas muchas propiedades en tu componente padre que necesitas pasar a los componentes hijos.

Seguro que los componentes hijos emiten eventos que cambiarán las propiedades de su padre.

Mantener todos estos cambios en orden y bajo control puede convertirse en una tarea tediosa porque las propiedades cambian en muchos lugares que pueden ser difíciles de rastrear, especialmente en tareas asíncronas.

La solución: @ngrx/component-store

El mismo equipo de NgRx desarrolló @ngrx/component-store. Un servicio basado en ReplaySubject puede extenderse a un servicio y ser consumido por un componente.

Permite mantener toda la lógica de negocio fuera del componente (o componentes) y sólo se suscribe al estado y actualiza la UI cuando cambia.

El servicio que creas al extender ComponentStore es único para un componente en particular y sus hijos y debe ser inyectado directamente en la propiedad providers del componente.

¿Cuándo utilizar un @ngrx/store o un @ngrx/component-store?

En su aplicación, puede utilizar ambas. Ambas bibliotecas se complementan.

  1. Si el estado necesita persistir cuando cambias la URL, ese estado va en tu **estado global
  2. Si el estado necesita ser limpiado cuando cambias la URL, ese estado va en tu almacén de componentes.

Más información en Comparación de ComponentStore y Store.

Mi recomendación

Si no tienes ninguna libreria para gestinar el estado en tu app y quieres empezar con una, te recomiendo empezar con @ngrx/component-store y evaluar si necesitas algo más complicado en el futuro.

De esta forma, puedes empezar a implementar la gestión de estados en partes de tu app y escalar de forma eficiente.

Conceptos de @ngrx/component-store

Sólo tiene tres conceptos muy sencillos que tienes que aprender:

  1. Selectores: Seleccionas y te suscribes al estado, ya sea todo o parte de él
  2. Actualizador: Para actualizar el estado. Puede ser por partes o en su totalidad
  3. Efectos: Es también actualizar el estado pero hacer alguna otra tarea necesaria previamente. Por ejemplo, una petición HTTP a una API

Comenzando

La aplicación tendrá una UI con tres secciones:

  1. Formulario para añadir el carrito
  2. Tabla con los carros aparcados
  3. Mensajes de error
Parking lot app demo

Inicialización de la aplicación

El primer paso es crear una nueva aplicación Angular. Con angular-cli. Abre un terminal, ejecuta el comando

ng new parking-lot-app

Iniciamos la aplicación que hemos creado:

cd parking-lot-app
ng serve

A continuación, apunta tu navegador a [http://localhost:4200/](http://localhost: 4200/), y verás tu aplicación Angular funcionando con toda la información por defecto.

Creando utilidades

Lo primero que vas a crear es la interfaz "Coche ". Ejecuta el comando

ng g interface models/car

Abre el archivo app/models/car.ts y añade:

export interface Car {
    plate: string
    brand: string
    model: string
    color: string
}

Lo anterior es el modelo muy básico del coche.

Entonces creas un servicio que se comunicará con el "backend" (sólo para la demo). Ejecutas el comando

ng g service services/parking-lot

Abre el archivo app/services/parking-lot.service.ts y añade:

import { Injectable } from '@angular/core'
import { Observable, of, throwError } from 'rxjs'
import { delay } from 'rxjs/operators'
import { Car } from '../models/car'

const data: Car[] = [
    {
        plate: '2FMDK3',
        brand: 'Volvo',
        model: '960',
        color: 'Violet',
    },
    {
        plate: '1GYS4C',
        brand: 'Saab',
        model: '9-3',
        color: 'Purple',
    },
    {
        plate: '1GKS1E',
        brand: 'Ford',
        model: 'Ranger',
        color: 'Indigo',
    },
    {
        plate: '1G6AS5',
        brand: 'Volkswagen',
        model: 'Golf',
        color: 'Aquamarine',
    },
]

const FAKE_DELAY = 600

@Injectable({
    providedIn: 'root',
})
export class ParkingLotService {
    private cars: Car[] = []

    constructor() {}

    add(plate: string): Observable<Car> {
        try {
            const existingCar = this.cars.find((eCar: Car) => eCar.plate === plate)

            if (existingCar) {
                throw `This car with plate ${plate} is already parked`
            }

            const car = this.getCarByPlate(plate)
            this.cars = [...this.cars, car]

            return of(car).pipe(delay(FAKE_DELAY))
        } catch (error) {
            return throwError(error)
        }
    }

    private getCarByPlate(plate: string): Car {
        const car = data.find((item: Car) => item.plate === plate)

        if (car) {
            return car
        }

        throw `The car with plate ${plate} is not register`
    }
}

Datos: Una lista de los coches registrados en nuestro sistema. Actuará como su base de datos de coches para la demo.

FAKE_DELAY: Para simular un pequeño retraso en la solicitud de la API utilizando el operador delay de rxjs.

Métodos:

add: que recibe la matrícula del vehículo y si existe la añade a la lista de coches aparcados y si no devuelve un error.

getCarByPlate: este método privado sólo busca en nuestra "base de datos" (data) el coche con la matrícula, y si no existe, lanza un error.

**Propiedades

car: Para llevar la cuenta de los coches aparcados en el "backend".

Definir el estado

Para definir el estado, veamos los requisitos de la aplicación:

  1. El usuario añadirá coches por matrícula (una petición a una API)
  2. Debe indicar al usuario los errores:
    • La matrícula del vehículo no existe en la API
    • El vehículo ya está aparcado
  3. Debe mostrar indicadores en la interfaz de usuario cuando se produce una solicitud
    • Cargar: cambiar el texto del botón mientras ocurre la solicitud
    • Desactivar: el botón y el campo de texto mientras ocurre la solicitud
    • Mostrar el error cuando se produce

En base a estos requisitos, el estado de su UI sería el siguiente

interface State {
    cars: Car[]
    loading: boolean
    error: string
}
  1. Una lista de coches aparcados
  2. Un booleano para cuando la aplicación hace una petición
  3. Una cadena para los mensajes de error

Instalar @ngrx/component-store

Para añadir @ngrx/component-store a tu aplicación utiliza npm:

npm install @ngrx/component-store --save

Creación del servicio de tienda

Crea el archivo app/store.service.ts y añade el siguiente código:

import { Injectable } from '@angular/core'
import { ComponentStore } from '@ngrx/component-store'
import { Car } from './models/car'

// The state model
interface ParkingState {
    cars: Car[] // render the table with cars
    error: string // show the error when try to add cars
    loading: boolean // used to enable/disable elements in the UI while fetching data
}

@Injectable()
export class StoreService extends ComponentStore<ParkingState> {
    constructor() {
        super({
            cars: [],
            error: '',
            loading: false,
        })
    }
}

Este código es la base de su StoreService:

  1. Has importado Injectable (como cualquier otro servicio) y ComponentStore.
  2. Has creado una interfaz ParkingState que define el estado de tu componente
  3. Has creado la clase StoreService que extiende de ComponentStore y pasa la interfaz
  4. Has inicializado el estado de la UI a través del constructor, haciendo que el estado esté disponible inmediatamente para los consumidores de ComponentStore.

Ahora vas a añadir el resto del código, selectos, actualizadores y efectos. Tu código de servicio sería:

import { Injectable } from '@angular/core'

import { ComponentStore } from '@ngrx/component-store'
import { EMPTY, Observable } from 'rxjs'
import { catchError, concatMap, finalize, tap } from 'rxjs/operators'
import { Car } from './models/car'
import { ParkingLotService } from './services/parking-lot.service'

// The state model
interface ParkingState {
    cars: Car[] // render the table with cars
    error: string // show the error when try to add cars
    loading: boolean // used to enable/disable elements in the UI while fetching data
}

@Injectable()
export class StoreService extends ComponentStore<ParkingState> {
    constructor(private parkingLotService: ParkingLotService) {
        super({
            cars: [],
            error: '',
            loading: false,
        })
    }

    // SELECTORS
    readonly vm$: Observable<ParkingState> = this.select((state) => state)

    // UPDATERS
    readonly updateError = this.updater((state: ParkingState, error: string) => {
        return {
            ...state,
            error,
        }
    })

    readonly setLoading = this.updater((state: ParkingState, loading: boolean) => {
        return {
            ...state,
            loading,
        }
    })

    readonly updateCars = this.updater((state: ParkingState, car: Car) => {
        return {
            ...state,
            error: '',
            cars: [...state.cars, car],
        }
    })

    // EFFECTS
    readonly  = this.effect((plate$: Observable<string>) => {
        return plate$.pipe(
            concatMap((plate: string) => {
                this.setLoading(true)
                return this.parkingLotService.add(plate).pipe(
                    tap({
                        next: (car) => this.updateCars(car),
                        error: (e) => this.updateError(e),
                    }),
                    finalize(() => {
                        this.setLoading(false)
                    }),
                    catchError(() => EMPTY)
                )
            })
        )
    })
}

Es bastante código, así que os lo explicaré por partes y empezaré por los selectores.

Selectores

Para crear un selector, se utiliza el método select de la siguiente manera:

readonly vm$: Observable<ParkingState> = this.select(state => state);

El método select espera una función que reciba el estado completo. Con este estado, podemos devolver a los componentes lo que se necesita; en este caso, devuelve el estado completo.

En esta aplicación, se necesita un selector, pero se puede tener más de uno.

Actualizadores

Para actualizar el estado, necesitarás tres actualizadores:

  1. Para añadir o eliminar el mensaje de error
  2. Para actualizar la carga
  3. Para añadir coches al aparcamiento

Para crear actualizadores, utiliza el método update proporcionado por la clase ComponentStore.

El método recibe una función con dos parámetros, el primero es el estado actual, y el segundo es la carga útil que el componente envió para actualizar el estado. Este método sólo tiene que devolver el nuevo estado.

Error y loading
readonly updateError = this.updater((state: ParkingState, error: string) => {
    return {
        ...state,
        error
    };
});

readonly setLoading = this.updater(
    (state: ParkingState, loading: boolean) => {
        return {
            ...state,
            loading
        };
    }
);

El updateError recibe el mensaje de error y utiliza el operador spread para combinarlo con el estado anterior y devolver el nuevo estado.

El setLoading funciona igual que el anterior pero con la propiedad loading.

Añadir coches al parking

Este actualizador recibe un coche y simplemente lo añade al array de coches utilizando el operador spread.

readonly updateCars = this.updater((state: ParkingState, car: Car) => {
    return {
        ...state,
        error: '',
        cars: [...state.cars, car],
    };
});

IMPORTANTE: Cuando se actualiza el estado, no se muta el objeto (cambiando alguna propiedad directamente) sino que se devuelve un nuevo objeto siempre.

Efectos

Para añadir un coche al aparcamiento, hay que crear un efecto porque hay que hacer una petición a una API con la matrícula del coche, y cuando responde, se actualiza el estado.

Para crear los efectos utilizamos el método effect que recibe un callback con el valor que le pasamos como Observable. Ten en cuenta que cada nueva llamada del efecto empujaría el valor a ese Observable.

readonly addCarToParkingLot = this.effect((plate$: Observable<string>) => {
    return plate$.pipe(
        concatMap((plate: string) => {
            this.setLoading(true);
            return this.parkingLotService.add(plate).pipe(
                tap({
                    next: car => this.updateCars(car),
                    error: e => this.updateError(e)
                }),
                finalize(() => {
                    this.setLoading(false);
                }),
                catchError(() => EMPTY)
            );
        })
    );
});

En este código, se puede ver que el efecto:

  1. Recibe la matrícula del coche como un Observable.
  2. Actualizar el estado de loading.
  3. Solicitar a la API que añada el coche al aparcamiento mediante el ParkingLotService.
  4. Cuando la solicitud tenga éxito, actualiza el estado de nuevo: elimina la carga y añade el carro al estado.
  5. Si falla: quitar la carga y actualizar el estado con el error que viene del "backend"

Usar concatMap para que si el effect es llamado varias veces antes de que termine la llamada, resuelva todas las llamadas. Este operador RxJS esperará hasta que la petición anterior se complete para hacer la siguiente.

El operador tap para manejar el caso de éxito y error.

Y el operador catchError para manejar posibles errores dentro de la tubería interna.

Creando el componente <car-list>

Ejecuta el siguiente comando para generar el componente.

ng g component components/car-list

En el archivo components/car-list.component.ts, añade el siguiente código:

import { Component, Input } from '@angular/core'
import { Car } from '../../models/car'

@Component({
    selector: 'app-car-list',
    templateUrl: './car-list.component.html',
    styleUrls: ['./car-list.component.css'],
    providers: [],
})
export class CarListComponent {
    @Input() cars: Car[] = []

    constructor() {}
}

En el archivo components/car-list.component.html, añade el siguiente código:

<table *ngIf="cars.length; else noCars">
    <tr>
        <th>Plate</th>
        <th>Brand</th>
        <th>Model</th>
        <th>Color</th>
    </tr>
    <ng-template ngFor let-car [ngForOf]="cars" let-i="index">
        <tr>
            <td>{{car.plate}}</td>
            <td>{{car.brand}}</td>
            <td>{{car.model}}</td>
            <td>{{car.color}}</td>
        </tr>
    </ng-template>
</table>

<ng-template #noCars>
    <p>No cars in the parking lot</p>
</ng-template>

En el components/car-list.component.css hacemos que la tabla se vea elegante:

table {
    width: 100%;
    border-collapse: collapse;
}

td,
th {
    border: solid 1px lightgray;
    padding: 0.5rem;
    text-align: left;
    width: 25%;
}

th {
    border-bottom-width: 3px;
}

p {
    text-align: center;
}

Por último, asegúrate de que el componente car-list está añadido al módulo.

Abre el archivo app/app.module.ts, mira en el array declarations, y si no está ahí, puedes añadir la clase CarListComponent manualmente.

Añadiendo el FormModule

Como vas a tener un pequeño formulario con [(ngModel)] en el app.component, debes añadir el FormModule al app.module.

Abre el archivo app/app.module.ts y añade el FormsModule al array imports. El código final se ve así:

import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'

import { AppComponent } from './app.component'
import { CarListComponent } from './components/car-list/car-list.component'
import { FormsModule } from '@angular/forms'

@NgModule({
    declarations: [AppComponent, CarListComponent],
    imports: [BrowserModule, FormsModule],
    bootstrap: [AppComponent],
})
export class AppModule {}

Consumir el servicio de la tienda

Has creado el servicio específicamente para la app.component y sus hijos.

app/app.component.ts

Añadir reemplazar todo el código con:

import { Component } from '@angular/core'
import { StoreService } from './store.service'

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css'],
    providers: [StoreService],
})
export class AppComponent {
    plate = ''
    vm$ = this.store.vm$

    constructor(private store: StoreService) {}

    onSubmit($event: Event) {
        $event.preventDefault()
        this.store.addCarToParkingLot(this.plate)
    }

    addPlate($event: Event) {
        const target = $event.target as HTMLButtonElement

        if (target.nodeName === 'BUTTON') {
            this.plate = target.innerHTML
        }
    }
}

El StoreService maneja toda la lógica de negocio, lo que resulta en un componente diminuto. Veamos el código parte por parte:

Proveedores

proveedores: [StoreService]: Se inyecta el servicio a nivel de componente para que esta instancia sólo tenga este componente y sus hijos.

Propiedades

plate: Para el modelo de formulario, el usuario introducirá la matrícula del coche a añadir al aparcamiento.

vm$ Es el estado observable de nuestro StoreService y se actualiza cada vez que el estado cambia. Nos suscribiremos a esto en el HTML en el siguiente paso.

Métodos

constructor(private store: StoreService) {}: Inyectas el StoreService en el constructor, como un servicio normal.

onSubmit(): Lo llamas cuando se envía el formulario, y lo único que hace es llamar al método del store addCarToParkingLot (efecto) con la matrícula del coche introducida por el usuario en el formulario.

El método addPlate(): Este método no es necesario, pero por motivos de demostración, lo he añadido para introducir algunas matrículas pulsando unos botones.

app/app.component.html

Añadir reemplazar todo el código con:

<header>
    <h1>Parking Lot Control</h1>
</header>

<ng-container *ngIf="vm$ | async as vm">
    <div class="messages">
        <p class="error" *ngIf="vm.error">{{vm.error}}</p>
    </div>

    <div class="box">
        <form (submit)="onSubmit($event)">
            <input
                type="text"
                [(ngModel)]="plate"
                [ngModelOptions]="{standalone: true}"
                placeholder="Ex: 2FMDK3, 1GYS4C, 1GKS1E,1G6AS5"
                [disabled]="vm.loading"
            />
            <button type="submit" [disabled]="vm.loading || !plate.length">
                <ng-container *ngIf="vm.loading; else NotLoading">
                    Loading...
                </ng-container>
                <ng-template #NotLoading>
                    Add Car
                </ng-template>
            </button>
        </form>
        <div class="shortcuts">
            <h5>Shortcuts</h5>
            <p (click)="addPlate($event)" class="examples">
                <button>2FMDK3</button>
                <button>1GYS4C</button>
                <button>1GKS1E</button>
                <button>1G6AS5</button>
            </p>
        </div>
    </div>

    <app-car-list [cars]="vm.cars"></app-car-list>
</ng-container>

<ng-container *ngIf="vm$ | async as vm">: Lo primero es obtener el ViewModel de la propiedad vm$ que creamos en la clase componente, usamos async pipe para suscribirnos, y hacemos una variable estática vm que el resto de nuestro HTML podrá usar.

Mensaje de error

El error es una string, por lo que sólo tenemos que mostrarlo en el HTML y utilizando la interpolación:

<p class="error" *ngIf="vm.error">{{vm.error}}</p>

Formulario

Creamos un formulario para que el usuario introduzca la matrícula del coche que quiere añadir al aparcamiento, y enlazamos el evento onSubmit.

<form (submit)="onSubmit()">

Es un pequeño formulario con un campo de texto para que el usuario introduzca la matrícula y un botón para ejecutar la acción de añadir.

<input>: Habilita/deshabilita en función de la propiedad loading del estado.

<botón>: Se habilita/deshabilita con la propiedad loading del estado pero también si la propiedad plate del componente está vacía (evita que se envíe una string vacía al servicio de la tienda)

En el método onSubmit del componente, llamamos al efecto con el número de placa introducido por el usuario, y aquí es donde nuestro servicio ComponentStore lo hace todo.

app/app.component.css

Añadimos algunos estilos para que nuestra aplicación se vea muy bien:

h1 {
    margin-bottom: 0;
}

.box {
    border: solid 1px lightgrey;
    padding: 1rem;
    display: flex;
    justify-content: space-between;
    margin-bottom: 1rem;
}

.box p {
    margin: 0;
}

.box form {
    display: flex;
}

.box form input {
    margin-right: 0.5rem;
}

.box form button {
    width: 80px;
}

.messages {
    height: 2.4rem;
    margin: 1rem 0;
}

.messages p {
    border: solid 1px transparent;
    margin: 0;
    padding: 0.5rem;
}

.messages .error {
    background-color: lightyellow;
    border: solid 1px red;
    color: red;
    text-align: center;
}

.examples button {
    border: 0;
    background: none;
    color: blue;
    text-decoration: underline;
    cursor: pointer;
    padding: 0;
    margin: 0 0.5rem 0 0;
}

.examples button:last-child {
    margin: 0;
}

.shortcuts h5 {
    margin: 0;
}

.code {
    margin-top: 3rem;
    border: solid 1px lightgray;
    padding: 1rem;
}

.code h4 {
    margin: 0 0 1rem;
}

.code pre {
    margin: 0;
}

Y en el archivo de estilo global src/styles.css:

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
        sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
    margen: 3rem;
}

Eso es todo

Ve a tu navegador: https://localhost:4200 y vea su aplicación funcionando.

Resumen

  1. Has creado un servicio que se comunica con la API: ParkingLotService.
  2. Has creado un servicio que maneja toda la lógica y el estado del componente StoreService que extiende a ComponentStore.
  3. Tu UI se suscribe al estado del StoreService, y cada vez que cambia, tu UI se actualiza.

Usando este enfoque, terminarás con una única "fuente de verdad" para tu UI, fácil de usar sin tener que cambiar el código en muchos lugares para actualizar o mejorar.

Conclusión

Como has podido ver, es mejor empezar a gestionar el estado a nivel de componente antes de saltar a una arquitectura completa.

Un estado es simplemente un objeto que representa el aspecto de tu interfaz, y utilizando @ngrx/component-store y sus tres conceptos básicos: select,update y effect, puedes manejarlo de una manera simple, directa y más indolora prueba.

Copyright © 2024. Design and code by myself with Next.js. Fork it and create yours