DEV Community

Rubén Aguilera Díaz-Heredero
Rubén Aguilera Díaz-Heredero

Posted on

Gestión del estado de una SPA con BLoC

Introducción

La gestión del estado en una SPA, es una de las cosas más importantes a tener en cuenta, para que la experiencia de usuario sea la más adecuada.

Por poner un ejemplo de su importancia, ninguna persona que use nuestra aplicación querrá tener que volver a hacer login porque simplemente ha refrescado la página o tener que volver a buscar el resultado en una tabla cuando vuelve del detalle, entre otros tantos ejemplos.

De esto ya se hemos hablado cómo hacerlo utilizando solo RxJS y hay otras soluciones como Valtio que nos permiten controlar el estado de la aplicación. Pero podemos hacerlo sin usar ninguna librería, controlando nosotros todo el código.

Para ellos vamos a hablar en este artículo de los BLoCs, que ya fueron mencionados en el artículo de Arquitectura limpia en el front donde tenéis un ejemplo de su uso con React.

Pero, ¿qué es un BLoC?
Son las siglas de Business Logic Component, es un patrón muy conocido en el desarrollo con Flutter, y que se puede aplicar de igual forma al desarrollo web.

Dentro de la arquitectura limpia propuesta, es el pegamento entre las capas de UI y Core. Existen librerías que implementan este patrón pero aquí veremos que su implementación no es nada difícil y que además nos puede proporcionar persistencia en el estado de nuestra SPA de una forma muy sencilla.

Implementación propuesta para TypeScript

Lo más habitual es encapsular toda la funcionalidad del BLoC en una clase base de la que extenderan todos los distintos BLoCs que necesite nuestra aplicación.

Una posible implementación de esta clase base en TypeScript sería esta:

export class BaseBloc<T> {
  private subscribers: ((state: T) => void)[] = [];
  protected state: T = {} as T;
  private persistKey: string;

  constructor(persistKey: string) {
    this.persistKey = persistKey;
    this.loadPersistedState();

    window.addEventListener("beforeunload", () => {
      this.savePersistedState();
    });
  }

  private loadPersistedState() {
    const persistedState = localStorage.getItem(this.persistKey);
    if (persistedState) {
      this.state = JSON.parse(persistedState) as T;
    }
    localStorage.removeItem(this.persistKey);
  }

  private savePersistedState() {
    localStorage.setItem(this.persistKey, JSON.stringify(this.state));
  }

  subscribe(callback: (state: T) => void) {
    this.subscribers.push(callback);
  }

  unsubscribe(callback: (state: T) => void) {
    const index = this.subscribers.indexOf(callback);
    if (index !== -1) {
      this.subscribers.splice(index, 1);
    }
  }

  protected notifySubscribers() {
    this.subscribers.forEach((callback) => {
      callback(this.state);
    });
  }

  setState(newState: Partial<T>) {
    this.state = { ...this.state, ...newState };
    this.notifySubscribers();
  }

  getState() {
    return this.state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Estos son los componentes clave de la clase:

Constructor

constructor(persistKey: string) {
  this.persistKey = persistKey;
  this.loadPersistedState();

  window.addEventListener("beforeunload", () => {
    this.savePersistedState();
  });
}
Enter fullscreen mode Exit fullscreen mode

El constructor recibe un parámetro persistKey que se utiliza como clave para almacenar el estado persistente en el almacenamiento local (localStorage). Al inicializar una instancia de BaseBloc, se carga el estado persistente previo (si existe) y se establece un evento que guarda el estado persistente antes de que el navegador se refresque.

Métodos loadPersistedState() y savePersistedState()

private loadPersistedState() {
  const persistedState = localStorage.getItem(this.persistKey);
  if (persistedState) {
    this.state = JSON.parse(persistedState) as T;
  }
  localStorage.removeItem(this.persistKey);
}

private savePersistedState() {
  localStorage.setItem(this.persistKey, JSON.stringify(this.state));
}
Enter fullscreen mode Exit fullscreen mode

Estos métodos se encargan de cargar y guardar el estado persistente utilizando el localStorage. El estado previamente guardado se carga en la propiedad state cuando se crea una nueva instancia del componente y se elimina del localstorage una vez está cargado, a fin de no exponer la información del estado de forma permanente. Al refrescar el navegador, el estado actual se almacena en el localStorage.

Métodos de suscripción y notificación

subscribe(callback: (state: T) => void) {
  this.subscribers.push(callback);
}

unsubscribe(callback: (state: T) => void) {
  const index = this.subscribers.indexOf(callback);
  if (index !== -1) {
    this.subscribers.splice(index, 1);
  }
}

protected notifySubscribers() {
  this.subscribers.forEach((callback) => {
    callback(this.state);
  });
}
Enter fullscreen mode Exit fullscreen mode

Estos métodos permiten que otros componentes o partes de la aplicación se suscriban al estado del componente BaseBloc. La función subscribe agrega un nuevo callback a la lista de suscriptores. Con unsubscribe, un callback puede ser eliminado de la lista de suscriptores si ya no es necesario recibir actualizaciones. Cuando el estado cambia mediante setState(), notifySubscribers() se encarga de notificar a todos los suscriptores para que puedan reaccionar a los cambios.

Métodos setState() y getState()

setState(newState: Partial<T>) {
  this.state = { ...this.state, ...newState };
  this.notifySubscribers();
}

getState() {
  return this.state;
}
Enter fullscreen mode Exit fullscreen mode

Estos métodos son fundamentales para manipular y acceder al estado de la lógica de negocio. setState() actualiza el estado actual mediante una copia superficial (shallow copy) de newState, lo que garantiza que no se modifique directamente el estado original. Después de actualizar el estado, se llama a notifySubscribers() para informar a los suscriptores sobre el cambio. getState() simplemente devuelve el estado actual.

Uso de BaseBloc

Ahora imagina que tenemos que implementar un loading general de la aplicación, para hacer esto vamos a crear la clase LoadingBloc con el siguiente contenido:

import { BaseBloc } from "./base";

export type LoadingState = {
  loading: boolean;
};

export class LoadingBloc extends BaseBloc<LoadingState> {
  private static instance: LoadingBloc;

  public static getInstance(): LoadingBloc {
    if (!LoadingBloc.instance) {
      LoadingBloc.instance = new LoadingBloc();
    }
    return LoadingBloc.instance as LoadingBloc;
  }

  private constructor() {
    super("loading_state");
    this.setState({ loading: true });
  }

  show() {
    this.setState({ loading: true });
  }

  hide() {
    this.setState({ loading: false });
  }
}
Enter fullscreen mode Exit fullscreen mode

Como vemos en la propia clase se define el tipo estado que vamos a almacenar, en este caso, una propiedad booleana loading.

Un punto fundamental para que se pueda gestionar correctamente el estado es que nos tenemos que asegurar de que esta clase solo tenga una instancia en toda la aplicación, porque de lo contrario no podría compartir el estado.

Como vemos esto se consigue con la implementación del patrón Singleton, donde ponemos como privado el constructor y el método getInstance() quien se encarga de mantener una única instancia.

Luego, para esta funcionalidad, necesitamos dos métodos públicos: show() que se encarga de poner a true la propiedad loading y hide() que se encarga de ponerla a false.

Como ya se comento anteriomente el método setState() se encarga de notificar a todos los componentes de la aplicación que se hayan suscrito a los cambios de este BLoC.

Además fíjate que el test de esta clase queda tan sencillo como realizar una serie de acciones y ver que el estado es el esperado. Para este BLoC este sería su test asociado:

import { LoadingBloc } from "./loading.bloc";

describe("Loading Bloc", () => {
  let loadingBloc: LoadingBloc;

  beforeEach(() => {
    loadingBloc = LoadingBloc.getInstance();
  });

  test("should set loading state properly", () => {
    loadingBloc.show();
    expect(loadingBloc.getState().loading).toBe(true);
    loadingBloc.hide();
    expect(loadingBloc.getState().loading).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

Ejemplo de uso del BLoC con Angular

En el artículo de arquitectura limpia viene un ejemplo de cómo hacerlo con React, así que aquí vamos a ver cómo hacer uso del BLoC dentro de una aplicación de Angular.

Imaginamos que necesitamos conocer el estado del loading en un componente header de nuestra aplicación, esta podría ser una posible implementación:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { LoadingBloc, LoadingState } from 'src/core/blocs/loading.bloc';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.css']
})
export class HeaderComponent implements OnInit, OnDestroy{

  loading: boolean = false;
  loadingBloc = LoadingBloc.getInstance();
  handleState = (state: LoadingState) => {
    this.loading = state.loading;
  }

  ngOnInit(): void {
    this.loadingBloc.subscribe(this.handleState);
  }

  ngOnDestroy(): void {
    this.loadingBloc.unsubscribe(this.handleState);
  }

}
Enter fullscreen mode Exit fullscreen mode

De forma que este SmartComponent, ya que tiene que conocer el estado de la aplicación, se puede apoyar en su template en n DumbComponents para mostrar de la forma que sea precisa el estado de loading de nuestra aplicación, pudiendo ser modificada desde cualquier punto de la misma.

Fíjate que el SmartComponent se encarga de hacer la suscripción y desuscripción del BLoC del método handleState(state) que recibe las actualizaciones del estado.

Conclusiones

Espero que este artículo sirva para entender mejor la parte de los BLoCs, que es quizá la pieza más importante en la arquitectura limpia propuesta.

Top comments (0)