Librerías del ecosistema

Por: Artiko
angularbuenas-practicaslibreriasngrxtanstack-querymaterial-cdkbundle-size

Librerías del ecosistema

Cada dependencia que agregás a tu package.json es una decisión de arquitectura con costo perpetuo: peso en el bundle, superficie de API que tu equipo tiene que aprender, acoplamiento a un mantenedor externo y una nueva ruta por donde entra una vulnerabilidad. El problema de calidad que ataca este capítulo no es “no conocer las librerías”, sino elegirlas con criterio: sumar una dependencia cuando resuelve un problema real que no podés resolver barato con lo que ya trae Angular, y rechazarla cuando estás importando 50 KB para algo que se hace en 15 líneas.

Angular 22 movió el piso: signal/computed/effect, httpResource, Signal Forms, @defer, standalone y zoneless resuelven de forma nativa muchas cosas que antes exigían una librería. Buena parte de este capítulo es, justamente, cuándo NO necesitás la librería porque el framework ya te la dio. La regla que atraviesa todo: cada librería tiene que ganarse su lugar contra la alternativa nativa.

El costo real de una dependencia

Antes de mirar librerías concretas, fijate qué estás pagando cada vez que instalás una:

flowchart TD
    A["¿Necesito esta librería?"] --> B{"¿Angular 22 lo\nresuelve nativo?"}
    B -->|"Sí (signals,\nhttpResource,\nSignal Forms, @defer)"| C["No la agregues.\nUsá lo nativo"]
    B -->|No| D{"¿El problema es\nreal y recurrente\nen el proyecto?"}
    D -->|"No, es puntual"| E["Escribí las\n15 líneas propias"]
    D -->|Sí| F{"¿Es tree-shakeable\ny pesa poco\npor lo que uso?"}
    F -->|No| G["Buscá alternativa\nmodular o aislala\ntras una fachada"]
    F -->|Sí| H{"¿Mantenida y\ncompatible con la\núltima major?"}
    H -->|No| I["Riesgo de deuda:\nevaluá alternativas"]
    H -->|Sí| J{"¿El equipo la\npuede sostener?"}
    J -->|"No, curva alta"| K["Sobra para el\ntamaño del equipo"]
    J -->|Sí| L["Adelante:\naislá el acoplamiento\ntras una fachada"]

Gestión de estado

Esta es la categoría donde más se sobre-diseña. En Angular 22, con signals estables, la pregunta correcta no es “¿NgRx o NGXS?” sino “¿necesito una librería de estado?”.

Primero: ¿alcanza con service + signals?

Para la mayoría del estado de una app, un servicio providedIn: 'root' con signals privados y expuestos como readonly es suficiente. Ya lo viste en profundidad en el capítulo de signals y estado; acá lo que importa es el criterio de escalada.

// Estado sin librería: service + signals. Suficiente para el 80% de los casos.
@Injectable({ providedIn: 'root' })
export class CarritoStore {
  private readonly items = signal<Item[]>([]);

  readonly lista = this.items.asReadonly();
  readonly total = computed(() => this.items().reduce((s, i) => s + i.precio, 0));
  readonly cantidad = computed(() => this.items().length);

  agregar(item: Item): void {
    this.items.update((prev) => [...prev, item]);
  }

  quitar(id: string): void {
    this.items.update((prev) => prev.filter((i) => i.id !== id));
  }
}

Esto es cero dependencias, tree-shakeable por definición, testeable sin TestBed y con un modelo mental que cualquiera entiende. Escalá a una librería de estado sólo cuando aparezcan varias de estas señales a la vez:

Si no tenés al menos dos de estas, una librería de estado es complejidad accidental: agregás API, boilerplate y una capa de indirección para un problema que signal resolvía.

NgRx SignalStore (moderno, basado en signals)

Si necesitás estructura pero no querés el ceremonial de Redux, el SignalStore de @ngrx/signals es hoy el punto dulce en Angular 22. Es funcional, componible con features y expone signals directamente.

import {
  signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';

type BookState = { books: Book[]; filter: string; loading: boolean };

const initialState: BookState = { books: [], filter: '', loading: false };

export const BookStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ books, filter }) => ({
    // computed derivado: se recalcula sólo cuando cambian books o filter
    visibles: computed(() =>
      books().filter((b) => b.title.includes(filter()))
    ),
  })),
  withMethods((store) => ({
    setFilter(filter: string): void {
      patchState(store, { filter });
    },
    setBooks(books: Book[]): void {
      patchState(store, { books, loading: false });
    },
  })),
);

Puntos clave: patchState actualiza de forma inmutable y type-safe; signalStore es tree-shakeable y no arrastra el runtime clásico de NgRx; las features (signalStoreFeature) te dejan componer comportamiento reutilizable (paginación, entidades con withEntities, etc.) sin herencia. Para efectos asincrónicos, rxMethod integra RxJS cuando lo necesitás (mirá RxJS con criterio).

NgRx Store clásico (Redux)

El @ngrx/store clásico —acciones, reducers, selectores, @ngrx/effects— sigue vigente y es la opción con más tooling (Redux DevTools, @ngrx/entity, @ngrx/router-store). Su costo es el boilerplate: para un contador necesitás una acción, un reducer, un selector y wiring. Ese ceremonial se paga solo en apps grandes con flujos que valen la trazabilidad; en una app mediana es sobrepeso. En proyecto nuevo sobre Angular 22, el SignalStore cubre la mayoría de los casos con una fracción del código.

NGXS y Elf

flowchart LR
    A["Estado en Angular 22"] --> B{"¿Compartido +\ncomplejo +\nefectos?"}
    B -->|No| C["service + signals\n(cap. 4)"]
    B -->|Sí| D{"¿Preferencia\ndel equipo?"}
    D -->|"Signals-first,\nmínimo boilerplate"| E["NgRx SignalStore"]
    D -->|"Redux + DevTools\n+ tooling maduro"| F["NgRx Store clásico"]
    D -->|"Clases +\ndecoradores"| G["NGXS"]
    D -->|"Liviano +\nmodular"| H["Elf"]

Data fetching y estado de servidor

Acá hay que separar dos cosas que se confunden: estado de cliente (lo de arriba) y estado de servidor (datos remotos con caché, revalidación, reintentos). Meter datos remotos en un store de cliente y sincronizarlos a mano es un antipatrón clásico: reimplementás caché, deduplicación e invalidación peor que una librería dedicada.

httpResource nativo: el punto de partida

Angular 22 trae httpResource (y rxResource), que ya te dan un recurso reactivo con estado de carga, error y valor, atado a signals. Para lecturas simples atadas a un parámetro reactivo, es suficiente y sin dependencias. Lo viste en HTTP, datos y complejidad.

// Nativo: recurso reactivo que se re-dispara cuando cambia el signal userId.
readonly userId = signal(1);
readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);
// user.value(), user.isLoading(), user.error() -> todos signals

httpResource cubre lecturas puntuales. Lo que no te da: caché compartida entre componentes por queryKey, deduplicación de peticiones concurrentes, revalidación en background, reintentos con backoff, invalidación tras mutaciones y stale-while-revalidate out of the box.

TanStack Query (Angular Query): cuándo sí

Cuando la app vive de datos de servidor —muchas vistas leyendo las mismas entidades, mutaciones que deben refrescar listas, offline/reintentos— @tanstack/angular-query-experimental te ahorra reimplementar toda esa maquinaria. injectQuery e injectMutation exponen el estado como signals y se integran con el control flow.

import {
  injectQuery, injectMutation, QueryClient,
} from '@tanstack/angular-query-experimental';

@Component({
  template: `
    @if (todos.isPending()) { <span>Cargando…</span> }
    @else if (todos.isError()) { <span>Error: {{ todos.error()?.message }}</span> }
    @else {
      @for (t of todos.data(); track t.id) { <li>{{ t.title }}</li> }
    }
    <button (click)="agregar()">Agregar</button>
  `,
})
export class TodosComponent {
  private readonly service = inject(TodoService);
  private readonly queryClient = inject(QueryClient);

  todos = injectQuery(() => ({
    queryKey: ['todos'],
    queryFn: () => this.service.getTodos(),
  }));

  add = injectMutation(() => ({
    mutationFn: (t: Todo) => this.service.addTodo(t),
    // invalidación declarativa: refresca la caché de 'todos' al terminar
    onSuccess: () => this.queryClient.invalidateQueries({ queryKey: ['todos'] }),
  }));

  agregar(): void {
    this.add.mutate({ id: crypto.randomUUID(), title: 'Nueva' });
  }
}

El setup es una línea en el bootstrap:

bootstrapApplication(App, {
  providers: [provideTanStackQuery(new QueryClient())],
});

Nota: el paquete Angular sigue marcado como experimental (fijá versión de patch en producción). El criterio: httpResource para lecturas simples; TanStack Query cuando la caché de servidor, la invalidación por clave y las mutaciones coordinadas son el corazón de la app. No lo agregues por una pantalla que hace un GET.

flowchart TD
    A["Necesito datos del servidor"] --> B{"¿Caché compartida,\ninvalidación por clave,\nreintentos, mutaciones\ncoordinadas?"}
    B -->|No| C["httpResource /\nrxResource nativo (cap. 10)"]
    B -->|Sí| D["TanStack Query\n(injectQuery / injectMutation)"]
    C --> E["¿Empieza a crecer\nla lógica de caché\na mano?"]
    E -->|Sí| D

Librerías de UI

Angular Material + CDK

Angular Material es la suite de componentes oficial (Material 3). Su mayor valor no es estético sino el CDK (Component Dev Kit), que podés usar sin adoptar el look de Material:

// Virtual scroll: sólo se crean en el DOM los ítems visibles.
@Component({
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport">
      @for (item of items(); track item.id) {
        <div class="fila">{{ item.nombre }}</div>
      }
    </cdk-virtual-scroll-viewport>
  `,
})
export class ListaGrande {
  readonly items = signal<Item[]>(cargarDiezMil());
}

Podés instalar sólo @angular/cdk y consumir estos primitivos sin @angular/material: aprovechás la accesibilidad y el overlay sin cargar los componentes visuales.

Alternativas: PrimeNG, Spartan

Criterio: si necesitás accesibilidad y overlay sólidos con diseño propio, CDK solo o Spartan. Si querés componentes visuales listos y coherentes, Material. Si necesitás un catálogo amplio de widgets de datos, PrimeNG.

Internacionalización (i18n)

Dos enfoques con trade-offs opuestos.

@angular/localize (oficial, build-time)

Marca strings con i18n en el template y $localize en TS. Extrae mensajes a XLIFF/XMB y compila un bundle por idioma. Ventaja: cero costo en runtime (el idioma está horneado) y es lo oficial. Desventaja: el cambio de idioma requiere recargar/servir otro bundle; no hay switch dinámico en caliente sin artificios. Encaja cuando los idiomas se sirven por dominio/ruta y el set es estable.

Transloco (runtime, dinámico)

@jsverse/transloco carga las traducciones en runtime (lazy por scope) y permite cambiar de idioma sin recargar. En Angular 22 tiene API de signals:

// Configuración en el bootstrap
provideTransloco({
  config: {
    availableLangs: ['es', 'en'],
    defaultLang: 'es',
    fallbackLang: 'es',
    reRenderOnLangChange: true,
  },
  loader: TranslocoHttpLoader,
});
// En componente: traducción reactiva como signal
readonly saludo = translateSignal('home.saludo', { nombre: 'Ana' });
<!-- En template: pipe o directiva estructural -->
{{ 'home.titulo' | transloco }}

<ng-container *transloco="let t">
  <h1>{{ t('home.titulo') }}</h1>
</ng-container>

Criterio: @angular/localize si querés lo oficial, bundle por idioma y no necesitás switch dinámico; Transloco si necesitás cambio de idioma en caliente, carga lazy por feature o una API de signals más ergonómica. El costo de Transloco es la traducción resuelta en runtime (algo de trabajo por render) a cambio de flexibilidad.

Formularios y validación con esquemas

Angular 22 trae Signal Forms y reactive forms tipados (ver Formularios). Para validación, muchas veces querés una única fuente de verdad de tipos y reglas compartida entre cliente y servidor: ahí entran esquemas como Zod.

El patrón: definís el esquema una vez, derivás el tipo con z.infer, y lo enchufás como validador del formulario en vez de duplicar reglas.

import { z } from 'zod';

// Fuente única: tipo + reglas de validación
const RegistroSchema = z.object({
  email: z.string().email('Email inválido'),
  edad: z.number().int().min(18, 'Debés ser mayor de edad'),
});
type Registro = z.infer<typeof RegistroSchema>;

// Validador que envuelve el parseo de Zod en un ValidatorFn
function zodValidator(schema: z.ZodType): ValidatorFn {
  return (control) => {
    const result = schema.safeParse(control.value);
    if (result.success) return null;
    // mapea los issues de Zod a errores de Angular
    return { zod: result.error.issues.map((i) => i.message) };
  };
}

Beneficio de complejidad: una sola definición de reglas evita el antipatrón de validar distinto en el formulario, en el interceptor y en el backend. El tipo Registro fluye por toda la app y el compilador te avisa si el shape cambia. En NestJS del lado servidor podés reusar el mismo esquema (v12 apunta a Standard Schema, que estandariza esta interoperabilidad). No agregues Zod para un formulario de login con dos campos; agregalo cuando las reglas son ricas y viajan entre capas.

Utilidades: el terreno donde más se desperdicia bundle

Fechas: date-fns vs moment

Evitá moment: es monolítico (~ decenas de KB tras gzip, con locales), mutable y prácticamente sin tree-shaking; el propio proyecto está en modo mantenimiento. date-fns es funcional y modular: importás sólo format, addDays, etc., y el tree-shaking descarta el resto. Para muchos casos, la Intl nativa (Intl.DateTimeFormat, Intl.RelativeTimeFormat) formatea fechas sin ninguna dependencia.

// Antipatrón: moment entero para formatear una fecha
import moment from 'moment';
const s = moment(fecha).format('DD/MM/YYYY'); // arrastra todo moment al bundle

// Mejor: import granular de date-fns (sólo entra format)
import { format } from 'date-fns';
const s2 = format(fecha, 'dd/MM/yyyy');

// Aún mejor para casos simples: Intl nativo, cero dependencias
const s3 = new Intl.DateTimeFormat('es-AR').format(fecha);

No importes lodash entero

import _ from 'lodash' arrastra toda la librería. Si de verdad necesitás una utilidad, importá el módulo puntual (import debounce from 'lodash-es/debounce') con lodash-es, que es tree-shakeable. Pero antes preguntate si el nativo alcanza: structuredClone, Array.prototype.flatMap, Object.groupBy, at(), optional chaining y nullish coalescing cubren hoy gran parte de lo que se usaba de lodash. Un debounce son 8 líneas propias.

// Antipatrón: toda la librería por una función
import _ from 'lodash';
const copia = _.cloneDeep(obj);

// Mejor: nativo moderno
const copia2 = structuredClone(obj);

// Si necesitás lodash de verdad: import granular tree-shakeable
import groupBy from 'lodash-es/groupBy';

Criterios de elección (resumen operativo)

Cuando dudes, puntuá la candidata contra estos ejes; si falla varios, no entra:

El antipatrón central

Sumar una librería pesada para un problema chico. Un carrusel de tres imágenes no justifica una librería de carruseles de 40 KB; un debounce no justifica lodash entero; un formulario de dos campos no justifica Zod; un GET no justifica TanStack Query. La señal de alarma: tu package.json crece más rápido que las features, y hay dependencias de las que usás una sola función. La táctica opuesta —reescribir a mano cosas complejas y sensibles (accesibilidad de un diálogo, caché de servidor con invalidación)— es el antipatrón inverso: ahí la librería madura te ahorra bugs sutiles. El criterio es proporcionalidad entre el peso de la dependencia y el tamaño real del problema.

Checklist

Ya tenés el criterio para decidir qué entra y qué no. En el próximo capítulo condensamos todo el curso en un catálogo accionable: los patrones que valen la pena y los antipatrones que hay que erradicar, con las señales de alarma para detectarlos en revisión de código. Seguí con Catálogo de patrones y antipatrones →.