NgRx es la librería de gestión de estados más popular para Angular. Se encuentra mucho en proyectos empresariales. Nx también se utiliza mucho en proyectos empresariales. Entonces, ¿cómo crear un espacio de trabajo Nx con NgRx?
Pero en primer lugar, vamos a hablar de por qué y cuándo se debe utilizar NgRx.

¿Por qué y cuándo utilizar NgRx?


A menudo se debate mucho si NgRx es excesivo y realmente necesario para un proyecto, debido a su complejidad, que no es beneficiosa para todos los proyectos, pero sí una clave del éxito para unos pocos.

La respuesta corta: Depende.

La respuesta larga: Depende del tamaño de su proyecto, la complejidad de su estado y el número de desarrolladores que trabajan en el proyecto.

La respuesta aún más larga:
La mayoría de las aplicaciones no necesitan un almacén Redux completo como NgRx, ¡hasta que lo necesitan!

Si tu quieres aprender más acerca de NGRX recuerda visitar el siguiente recurso 👉


Por lo general, empezar con un simple servicio basado en la gestión de estados es suficiente al principio y puede que tengas suerte y nunca necesites nada más. De hecho, muchas aplicaciones nunca necesitan nada más.

Pero a veces te encontrarás con una situación en la que necesitarás compartir estado entre servicios que no están directamente relacionados entre sí. Por lo tanto, el número de dependencias entre tus servicios aumentará con el tiempo y eso podría conducir a dependencias circulares al final. Este es un problema común con la gestión de estado basada en servicios y es difícil de resolver una vez que te encuentras en esa situación.

Una forma de evitar estas dependencias circulares es duplicar el estado y posiblemente también la lógica que está relacionada con ese estado. Por un lado, esto resolverá el problema de la dependencia circular, pero por otro lado, dará lugar a una gran cantidad de duplicación de código e inconsistencias en su estado.

Además, será difícil hacer un seguimiento de todos los lugares en los que se utiliza y actualiza el estado. Esto es también una violación obvia del principio DRY (Don't Repeat Yourself).
Si intentaras sincronizar el estado entre los diferentes servicios, acabarías de nuevo con dependencias circulares o tendrías que introducir tu sistema de eventos personalizado, lo que tampoco es una buena idea.

La solución más fácil y viable a este problema es introducir una librería de gestión de estado global como NgRx. Lo que al principio parecía una exageración, ahora se convierte en una necesidad que aporta simplicidad a una situación compleja. NgRx le ayudará a mantener un seguimiento de su estado y también le ayudará a mantener su estado consistente.

Si no estás familiarizado con NgRx y Redux, te recomiendo que leas primero la documentación oficial.

Arquitectura Nx para NgRx

Límites de los módulos


Si no estás familiarizado con el Enterprise Monorepo Pattern, te recomiendo que primero leas sobre él. La razón es que la arquitectura del espacio de trabajo Nx está basada en el Enterprise Monorepo Pattern pero con cambios específicos para NgRx.


En lugar de la tradicional librería de acceso a datos, que contiene lógica, estado, servicios y entidades, la dividiremos en librerías de estado y de acceso a datos. Mientras que la biblioteca de estado sólo contendrá las especificidades de NgRx como acciones, reductores, efectos y selectores, la biblioteca de acceso a datos sólo contendrá servicios de datos y entidades.

Además, introduciremos una biblioteca de DTOs que contendrá todos los objetos de transferencia de datos que se utilizan para comunicarse con el backend.

Esto es especialmente útil, porque podemos hacer uso de la regla @nrwl/nx/enforce-module-boundaries linting para asegurarnos de que los DTOs sólo se utilizan en la biblioteca de acceso a datos y en ningún otro lugar. De tal forma que la implementación del backend no se filtre al frontend.

"rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "enforceBuildableLibDependency": true,
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "type:app",
            "onlyDependOnLibsWithTags": ["type:feature", "type:routes", "type:ui", "type:state", "type:util"]
          },
          {
            "sourceTag": "type:routes",
            "onlyDependOnLibsWithTags": ["type:feature", "type:util", "type:state"]
          },
          {
            "sourceTag": "type:feature",
            "onlyDependOnLibsWithTags": ["type:ui", "type:state", "type:util"]
          },
          {
            "sourceTag": "type:ui",
            "onlyDependOnLibsWithTags": ["type:util"]
          },
          {
            "sourceTag": "type:util",
            "onlyDependOnLibsWithTags": []
          },
          {
            "sourceTag": "type:state",
            "onlyDependOnLibsWithTags": ["type:util", "type:data-access"]
          },
          {
            "sourceTag": "type:data-access",
            "onlyDependOnLibsWithTags": ["type:util", "type:dtos"]
          },
          {
            "sourceTag": "type:dtos",
            "onlyDependOnLibsWithTags": []
          }
        ]
      }
    ]
  }

Crear un Feature Slice para un Domini


Siguiendo el Enterprise Monorepo Pattern, dividimos verticalmente nuestra aplicación en dominios que deben ser tan independientes como sea posible. Pero esto no significa que cada uno de estos dominios deba tener su propia instancia de almacén. En lugar de eso, usamos un almacén global y creamos features slices para cada dominio.

Para configurar el almacén global para una aplicación Angular usando APIs independientes, necesitamos añadir provideEffects() y provideStore() al array de proveedores de la appConfig.

export const appConfig: ApplicationConfig = {
  providers: [
    provideEffects(),
    provideStore(),
    provideRouter(appRoutes),
    provideHttpClient(),
    !environment.production ? provideStoreDevtools() : []
  ],
};

Ahora podemos crear un feature slice para un dominio creando una nueva librería con la etiqueta type:state dentro de un dominio.

Esta biblioteca contiene todo el código específico de NgRx, como acciones, reductores, efectos y selectores. También tienes que asegurarte de exportar las acciones, reductores, efectos y selectores de la librería exportándolos en el index.ts de la librería state.

Puedes utilizar el generador Nx ngrx-feature-store para crear parte del código NgRx. Después de ejecutar el generador deberías ser capaz de ver un montón de archivos en tu biblioteca de estados.

Primero lo primero, echemos un vistazo al archivo actions. Hoy en día, estamos utilizando la función createActionGroup para crear un grupo de acciones, ya que ahorra algo de código boilerplate y ayuda con la estructura.

Por ejemplo, podemos hacer un grupo de acciones para las llamadas a la API de un feature slice y otro grupo sólo para acciones específicas de la página.

Es crucial no reutilizar acciones para tener una buena higiene de acciones, de forma que seas capaz de seguir el flujo de acciones en tu aplicación. Estructurar las acciones en grupos ayuda a conseguirlo.

export const initCustomers = createAction('[Customers Page] Init');

export const customerApiActions = createActionGroup({
  source: 'Customers API',
  events: {
    loadCustomers: emptyProps(),
    loadCustomersSuccess: props<{ customers: CustomersEntity[] }>(),
    loadCustomersFailure: props<{ error: unknown }>(),
  },
});

Dentro del archivo reducer deberías poder ver los reductores y el estado inicial de la feature slice.

export interface CustomersState extends EntityState<CustomersEntity> {
  selectedId: string | null;
  loaded: boolean;
  error: unknown | null;
}

export const customersAdapter: EntityAdapter<CustomersEntity> =
  createEntityAdapter<CustomersEntity>();
export const initialCustomersState: CustomersState =
  customersAdapter.getInitialState({
    selectedId: null,
    loaded: false,
    error: null,
  });

Puede que se pregunte dónde está la clave de característica, pero hoy en día ya no la necesitamos. La función createFeature reduce el papeleo de crear un segmento de característica y añade automáticamente la clave de característica. Y aún hace más.

También crea selectores para la feature slice automáticamente, pero aún puedes crear selectores extra. Con un poco de magia TypeScript bajo el capó, los selectores se crean automáticamente para cada propiedad en el estado.

Ten en cuenta que la función createFeature` requiere que no utilices propiedades opcionales en el estado. Esto significa que el uso de loaded?: boolean en el estado no está permitido. Para evitar esto, puedes hacer una unión de tipos como loaded: boolean | undefined o loaded: boolean | null.

const { selectAll } = customersAdapter.getSelectors();

/**
 * 👧🏻 Modern NgRx with out-of-the-box selectors
 */
export const customersFeature = createFeature({
  name: 'customers',
  reducer: createReducer(
    initialCustomersState,
    on(CustomersActions.initCustomers, (state) => ({
      ...state,
      loaded: false,
      error: null,
    })),
    on(customerApiActions.loadCustomersSuccess, (state, { customers }) =>
      customersAdapter.setAll(customers, { ...state, loaded: true })
    ),
    on(customerApiActions.loadCustomersFailure, (state, { error }) => ({
      ...state,
      error,
    }))
  ),
  extraSelectors: ({
    selectCustomersState,
  }) => ({
    selectAllCustomers: createSelector(
      selectCustomersState,
      (state: CustomersState) => selectAll(state)
    ),
  }),
});

Antes de poder utilizar la función, debemos proporcionar los efectos y la propia función al almacén global. Con las API independientes, esto se puede hacer añadiendo los efectos y la función a una configuración de rutas y añadiendo las funciones provideState() y provideEffects() a la matriz de proveedores.

export const routes: Routes = [
  {
    path: '',
    providers: [
      provideState(customersFeature),
      provideEffects(customersEffects),
    ],
    children: [
      {
        path: '',
        pathMatch: 'full',
        redirectTo: 'list',
      },
      {
        path: 'list',
        loadComponent: async () =>
          (await import('@ngrx-leaky-backends/customer/feature-list'))
            .FeatureListComponent,
      },
    ],
  },
];

Ahora, el almacén puede ser inyectado como de costumbre, pero hoy en día, no estamos obligados a recuperar observables de los selectores. Mediante el uso de la función selectSignal, podemos recuperar señales de la tienda también.

@Component({
  selector: 'ngrx-leaky-backends-feature-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './feature-list.component.html',
  styleUrls: ['./feature-list.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeatureListComponent implements OnInit {
  private readonly store = inject(Store);
  readonly customersSignal = this.store.selectSignal(customersFeature.selectAllCustomers);
  readonly customersLoadedSignal = this.store.selectSignal(
    customersFeature.selectLoaded
  );

ngOnInit() {
    this.store.dispatch(customerApiActions.loadCustomers());
  }
}

Arquitectura Hexagonal


Si quieres asegurarte de que tu aplicación es tan independiente del backend como sea posible, puedes introducir adicionalmente una arquitectura hexagonal. Por lo general, esto significa que tienes un módulo central que contiene toda la lógica de negocio y un módulo de características que contiene toda la lógica de la infraestructura.

El módulo de características se encarga de proporcionar al módulo central las dependencias necesarias. Los puertos y adaptadores se utilizan para garantizar que el módulo principal no dependa del módulo de características.

Yo sugeriría ser muy pragmática al respecto. Si estás trabajando en una aplicación pequeña, puede que no necesites una arquitectura hexagonal. De hecho, incluso si estás trabajando en una aplicación grande, puede que no necesites una arquitectura hexagonal.

Normalmente, los backends se benefician más de una arquitectura hexagonal que los frontends.

Pero... El concepto de Puertos y Adaptadores puede ser útil si realmente quieres tener esta independencia del backend. En este caso, puedes utilizar un servicio de puerto genérico que es responsable de la comunicación con el backend. La implementación real se proporciona en un adaptador específico.

Este adaptador puede ser un adaptador REST, un adaptador GraphQL, un adaptador mock o un adaptador Firebase. El consumidor del servicio de puerto no sabe qué adaptador se utiliza y, por lo tanto, no necesita saber cómo se implementa el backend.

El servicio de puerto debe ser un servicio abstracto implementado por los adaptadores. Los métodos abstractos deben ser realmente genéricos. También es importante que utilices una clave de proveedor useFactory o useClass para proporcionar un servicio de adaptador.

@Injectable({
  providedIn: 'root',
  useFactory: useCustomerAdapter,
})
export abstract class CustomerPortService {
  abstract loadOne(id: string): Observable<Customer>;
  abstract loadAll(): Observable<Customer[]>;
  abstract create(customer: Customer): Observable<Customer>;
  abstract update(id: string, customer: Customer): Observable<Customer>;
}

El servicio adaptador debe ser un servicio concreto que implemente el servicio de puerto abstracto. La implementación debe ser lo más específica posible.

Por ejemplo, si estás usando un adaptador REST, deberías usar el HttpClient para comunicarte con el backend. Si estás usando un adaptador GraphQL, deberías usar Apollo para comunicarte con el backend.

Si estás usando un adaptador Firebase, deberías usar AngularFire para comunicarte con el backend.

Si estás usando un adaptador mock, deberías usar un servicio mock para comunicarte con el backend. Aquí puedes ver un adaptador mock que se utiliza para simular el backend:

@Injectable({
  providedIn: 'root',
})
export class CustomerMockAdapterService implements CustomerPortService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/assets/customers.json';

loadOne(id: string): Observable<Customer> {
    return this.http.get<CustomerDto[]>(this.baseUrl).pipe(
      map((customersDto) =>
        customersDto.map(
          (customerDto) =>
            ({
              id: customerDto.id,
              firstName: customerDto.first_name,
              lastName: customerDto.last_name,
              dateOfBirth: new Date(customerDto.date_of_birth * 1000),
              email: customerDto.email,
              phone: customerDto.phone,
            } satisfies Customer)
        )
      ),
      map((customers) => customers.find((customer) => customer.id === id)),
      filter((customer): customer is NonNullable<typeof customer> => !!customer)
    );
  }
  loadAll(): Observable<Customer[]> {
    // ...
  }
  create(customer: Customer): Observable<Customer> {
    return of(customer);
  }
  update(id: string, customer: Customer): Observable<Customer> {
    return of(customer);
  }
}

La forma en que elegimos qué adaptador se debe utilizar se hace mediante la función de fábrica useCustomerAdapter. En mi implementación utilizo el entorno para determinar qué adaptador se debe utilizar. Si estoy en modo desarrollo, utilizo el adaptador mock. Si no, uso el adaptador REST.

export const useCustomerAdapter = () =>
  inject(ENVIRONMENT).mockBackend
    ? inject(CustomerMockAdapterService)
    : inject(CustomerAdapterService);

Ahora el CustomerPortService puede ser inyectado en los efectos de la tienda NgRx de tal forma que los efectos no dependan de la implementación del backend. De esta manera, podemos cambiar fácilmente entre diferentes implementaciones de backend porque el backend no se filtra en el frontend.

export const loadCustomers = createEffect(
    (actions$ = inject(Actions), customerService = inject(CustomerPortService)) =>
      actions$.pipe(
        ofType(customerApiActions.loadCustomers),
        switchMap(() =>
          customerService.loadAll().pipe(
            switchMap((customers) =>
              of(customerApiActions.loadCustomersSuccess({ customers }))
            ),
            catchError((error) => {
              console.error('Error', error);
              return of(customerApiActions.loadCustomersFailure({ error }));
            })
          )
        )
      ),
    { functional: true }
  );

Ten en cuenta que es importante añadir la opción functional: true al efecto cuando utilices efectos funcionales sin clases. De lo contrario, provideEffect() no funcionaría.

Esto es necesario para mantener la compatibilidad con los antiguos efectos basados en clases.

Conclusión


En este artículo, hemos visto como NgRx puede ser usado efectivamente en un espacio de trabajo Nx y como los límites del módulo pueden ser definidos. También hemos visto cómo el backend puede abstraerse del frontend utilizando el patrón de puertos y adaptadores.

Con todo, NgRx en sí es un tema muy debatido en la comunidad Angular - sin embargo, es un hecho que 1 de cada 6 proyectos Angular utilizan NgRx (mostrado por las estadísticas de npm) y muchos proyectos empresariales se benefician de ello.

Espero que este artículo te ayude a empezar con NgRx y Nx moderno y a utilizarlo en tu próximo proyecto.

Para ser justos, yo sería un poco reacia a utilizar el patrón de puertos y adaptadores en el mundo real, ya que añade cierta complejidad al proyecto y la probabilidad de reemplazar el backend en algún momento es muy baja en mi opinión. Sin embargo, definitivamente recomendaría ocultar los DTOs del frontend configurando los límites del módulo de una manera inteligente.

Fuente

Plataforma de cursos gratis sobre programación