NgRx: Mejores Prácticas y Modularidad – Parte 3

Traducción en castellano del artículo original de Rainer Hahnekamp «NgRx Buenas Prácticas Serie: 2. Modularidad» publicado el 11 diciembre 2021

En esta serie de publicaciones, estoy compartiendo las lecciones que he aprendido de la creación de aplicaciones reactivas en Angular utilizando la gestión de estado NgRx.

En la Sección 1 expliqué cómo comencé a utilizar NgRx. Segundo apartado  mostraba cómo añadir funcionalidad de almacenamiento en caché. Aquí, veremos la administración de estados desde un enfoque arquitectónico.

El código fuente se puede encontrar en GitHub.

Para más información sobre estos temas, puedes visitar mi blog

Modularidad: Contenedores, Presentación y Estado

Este diseño arquitectónico integra la gestión del estado en la popular arquitectura de contenedor/presentación.

Creamos un módulo exclusivo para el código relevante para NgRx.

Luego, dividimos nuestros componentes en otros dos módulos. El primero contiene los componentes del contenedor que son responsables de la interacción con el estado.

El segundo contiene los componentes de presentación. Son responsables de la visualización.

Tener tres módulos diferentes con responsabilidades claras mejora significativamente la capacidad de mantenimiento de nuestra aplicación.

El problema subyacente no es nuevo y es ampliamente conocido en otras áreas de la programación. Una clase/función/componente realiza múltiples tareas.

Si encontramos un componente que supera las 100 líneas de código, probablemente hemos identificado un caso. Debido a ese tamaño, es complicado entender rápidamente su funcionalidad.

Esto dificulta su mantenimiento y comprobación. Va en contra del principio de responsabilidad única.

Frecuentemente encontramos la misma situación en el mundo de Angular. Siempre hay un componente que contiene código complejo junto con el HTML y el CSS.

Ejemplos de código complejo o «lógica» pueden ser el envío de acciones a NgRx, el uso del router, o alguna interacción con servicios.

Es crucial tener en cuenta que Comunicarse con NgRx es una cosa, renderizar es otra. No realices todo en un solo componente.

Un componente debe realizar una sola tarea. Ya sea renderizar o ejecutar la «lógica». Esa es la premisa básica detrás del patrón de contenedores y componentes de presentación.

Así que tenemos un denominado componente de presentación que se encarga de la visualización. Principalmente contiene HTML y CSS.

En nuestro ejemplo, esto implicaría mostrar la lista o el formulario del cliente.

El componente desconoce cómo obtener datos de NgRx. Ni siquiera sabe si se emplea NgRx.

Solo recibe su entrada a través de @Input(). Tampoco sabe qué acción tomar cuando el usuario hace clic en un botón de envío.

Simplemente emite un evento a través de @Output() y espera que alguien en la jerarquía del componente sepa qué hacer.

export class CustomerComponent {

formGroup = new FormGroup({});

@Input() customer: Customer | undefined;
@Output() save = new EventEmitter<Customer>();
@Output() remove = new EventEmitter<Customer>();

fields: FormlyFieldConfig[] = [

// configuración del formulario

];

submit(customer: Customer) {

if (this.formGroup.valid) {
  this.save.emit(this.formGroup.value);
}

}

handleRemove(customer: Customer) {

  if (confirm(`¿Realmente eliminar ${customer}?`)) {
  this.remove.emit(this.customer);
}

}

}

Ese alguien es el componente contenedor.

Está al tanto del contexto. Sabe cómo interactuar con NgRx, qué selectores se requieren, cómo acceder a la ruta, entre otros. Desconoce cómo se visualizan los datos. Simplemente los obtiene, los transforma y los transmite.

Un componente contenedor comúnmente carece de CSS, solo contiene un HTML mínimo. Ese HTML incluye el selector del componente de presentación subyacente.

El componente contenedor tampoco debe tener ninguna vinculación de entrada o salida. Como componente consciente del contexto, comprende su posición en la aplicación, de dónde obtener todos los datos y a quién llamar cuando se produce un evento.

@Component({

selector: 'eternal-edit-customer',

template: ` <eternal-customer

*ngIf="customer$ | async as customer"

[customer]="customer"

(save)="this.submit($event)"

(remove)="this.remove($event)"

></eternal-customer>`,

})

export class EditCustomerComponent implements OnInit {

customer$: Observable<Customer> | undefined;

constructor(private store: Store, private route: ActivatedRoute) {}

ngOnInit() {

this.customer$ = this.store

.select(

fromCustomer.selectById(Number(this.route.snapshot.params.id || ''))

)

.pipe(

map((customer) => {

return { ...customer };

})

);

}

submit(customer: Customer) {

this.store.dispatch(

CustomerActions.update({ customer })

);

}
remove(customer: Customer) {
this.store.dispatch(
CustomerActions.remove({ customer })
);
}

}

Existen términos alternativos para este patrón. Algunos términos muy comunes son componente smart/dump.

Por supuesto, no siempre es factible cumplir con estas reglas estrictas. Habrá situaciones en las que un componente de presentación necesite hacer uso de la inyección de dependencias de Angular.

Por ejemplo, si desea mostrar un formulario (como la pantalla de detalles del cliente), debería inyectar el FormBuilder.

Lo mismo ocurre con los componentes contenedores. Puede haber casos de uso válidos en los que reciba datos a través de @Input desde un componente padre. Sin embargo, intentemos evitar tener múltiples niveles de componentes contenedores anidados interconectados a través de la vinculación de propiedades.

¿Qué ocurre con el Estado?

Dividir los componentes es la parte desafiante. Lo que sigue es más sencillo. Los tipos de componentes obtienen sus propios módulos.

A los módulos con los componentes contenedores los llamamos «feature» y al de las presentaciones «ui». Este es el esquema de nomenclatura que también utiliza nx (ver más abajo).

Lo mismo sucede con el estado. Movemos todo el código NgRx relevante a un tercer módulo denominado simplemente «data» (nx naming).

Casos concretos

Una ventaja evidente inmediata es que ahora podemos reutilizar el mismo componente de formulario pero con un comportamiento diferente.

El componente de presentación con el formulario es el mismo, ya sea para editar o añadir un cliente.

Los componentes contenedores se encargan de las distinciones. Dependiendo de las diferencias entre la edición o la adición, podemos tener dos variantes.

Entonces terminamos con tres componentes. A primera vista puede parecer excesivo. No es el caso.

Los componentes son muy pequeños y sabemos de inmediato dónde buscar si queremos modificar la lógica para editar o añadir un cliente (componentes contenedores) o si queremos simplemente cambiar el formulario en sí (componente de presentación).

Pruebas más sencillas

Esta separación estricta también tiene otras ventajas. Podemos crear fácilmente pruebas unitarias para el componente contenedor.

Realizar pruebas unitarias en el frontend es muy complicado. El problema radica en la mezcla entre el código real y el HTML/CSS. Con los componentes contenedores, solo queda el código.

Es una clase común con métodos y podemos escribir pruebas unitarias normales contra ella como lo haríamos en el backend.

Debemos decidir si es necesario probar la parte de código del componente de presentación. Podemos omitirla por completo o recurrir a una técnica de prueba especial como la Regresión Visual.

Esta decisión probablemente dependerá de nuestro tipo de aplicación y sus requisitos de calidad.

¿Más código = mejor?

Esto es para los lectores que están dando sus primeros pasos en la programación: Dividir los componentes facilita el mantenimiento de nuestra base de código.

Para los programadores principiantes, esto puede parecer contradictorio. Acabamos de crear dos o incluso tres componentes en lugar de uno solo.

Sumado a toda la terminología de los componentes (declaración de clase TypeScript, metadatos @Component), podríamos terminar teniendo aún más líneas de código que antes. ¿Por qué sería mejor?

El aumento del tamaño del código y una mejor mantenibilidad no son mutuamente excluyentes. Es algo normal. No eliminamos la complejidad, simplemente la dividimos en unidades más pequeñas.

Es más sencillo para nosotros trabajar en tres problemas pequeños en lugar de uno grande. O dicho de otra manera, es más fácil trabajar con 3 archivos con 45 líneas de código en lugar de un archivo con 120 líneas de código.

Las clases son más concisas y las responsabilidades están claras. Si necesitamos buscar errores en el estado, buscamos en el módulo de estado. Si el diseño está incorrecto, vamos al módulo de interfaz de usuario.

Si el estado es correcto pero los datos erróneos aparecen en la interfaz de usuario, probablemente sea un error en el componente contenedor.

Imponiendo reglas con Nx

Solo permitimos que los componentes contenedores se comuniquen con NgRx. ¿Cómo podemos hacer cumplir esto?

Utilizamos nx, una extensión para el CLI de Angular, y empleamos su capacidad para definir reglas de dependencia. ¡Ya está hecho!

A partir de ahora, cada vez que un componente de presentación intente utilizar algo del módulo de datos (=módulo relacionado con NgRx), obtendremos un aviso de linting y la compilación se interrumpirá.

Pero eso no es todo. Podemos ir más allá. En NgRx, queremos que ciertas acciones solo sean realizadas por los componentes contenedores.

Por ejemplo: Si utilizamos el patrón LoadStatus, entonces debería estar prohibido que cualquier componente fuera de NgRx despache el método load (o incluso loaded).

Para lograrlo, el módulo de datos solo exporta una lista limitada de acciones. Esta lista se define en su archivo index.ts. Cualquier componente puede acceder a una acción no exportada al hacer referencia directa al archivo de acciones de NgRx.

Pero esto se conoce como una importación profunda y nx intervendrá nuevamente y generará un aviso de linting.

const removed = createAction(

'[CUSTOMER] Eliminado',

props<{ customers: Customer[] }>()

);

export const CustomerActions = {

get,
load,
loaded,
add,
added,
update,
updated,
remove,
removed,
};

export const AccionesPublicasCliente = {

get,
add,
update,
remove,
};

Acciones NgRx exponiendo un conjunto para uso interno y otro para público

export * from './lib/customer-data.module';
export { PublicCustomerActions as CustomerActions } from './lib/customer.actions';
export * from './lib/customer.selectors';

index.ts exponiendo solo las acciones NgRx definidas en PublicCustomerActions

Los modelos

¿En cuál de los tres módulos deberíamos colocar las interfaces que definen nuestros modelos de dominio? En ninguno. Si ubicamos las interfaces en el módulo de estado o contenedor, nuestros componentes de UI no podrán usarlas como valores de entrada o salida.

A menos que nuestra aplicación solo tenga una interfaz de usuario genérica, esto podría ser problemático.

Tampoco podemos incluirlas en la UI porque no queremos vincular la gestión del estado con nuestra UI. Contamos con los componentes contenedores como mediadores.

El mejor lugar para los modelos sería un módulo propio en el que los tres módulos tengan una dependencia.

El porvenir

La próxima parte de esta serie abordará un tipo de problema muy común. Queremos redirigir al usuario después de que un efecto haya enviado exitosamente una solicitud al backend.

Otro escenario similar, basado en el mismo tipo de problema, es mostrar una notificación después de una comunicación con el backend.

¿Quién debería manejar eso, el efecto, acción? ¿Sería mejor en un componente? ¡Mantente alerta para conocer la respuesta!

En Grupo MET podemos ayudarte a implementar esta y muchas mas herramienta para optimizar tu trabajo. ¡Contáctanos para saber más!

Contactanos