Librerías del ecosistema
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:
- Peso transferido: lo que baja el usuario. Se mide en KB después de tree-shaking, minificación y compresión (gzip/brotli), no el número de npm. Una librería de 300 KB que es 100% tree-shakeable y de la que usás una función pesa casi nada; una de 70 KB monolítica pesa 70 KB aunque uses el 5%.
- Tree-shakeability: ¿está en ESM con
sideEffects: falsey exports granulares? Si el barrel te obliga a importar todo el paquete para usar una pieza, no es tree-shakeable y el bundle budget lo va a sentir. - Mantenimiento: frecuencia de releases, compatibilidad con la última major de Angular, tamaño de la comunidad, bus factor. Una librería sin release en 18 meses en un ecosistema que saca una major por año es deuda técnica futura.
- Acoplamiento: ¿cuánto de tu código toca su API? Una utilidad de fechas usada tras una función propia es trivial de reemplazar; un store cuyo modelo mental permea 200 componentes es un matrimonio.
- Superficie de seguridad: cada dependencia (y sus transitivas) es código de terceros ejecutándose en tu app. Repasá el capítulo de seguridad: más dependencias, más
npm auditpara revisar.
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:
- Estado compartido por muchas features con transiciones complejas entre sí.
- Necesidad de time-travel debugging, trazabilidad de acciones o redux devtools para depurar flujos difíciles.
- Un equipo grande donde imponer una convención estricta (acciones, reducers, efectos) reduce la varianza entre desarrolladores más de lo que cuesta el boilerplate.
- Efectos secundarios orquestados (secuencias de llamadas, cancelaciones, reintentos) que ensucian los servicios.
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
- NGXS: modelo orientado a clases con decoradores (
@State,@Action,@Selector), menos boilerplate que NgRx clásico y curva más suave. Opción razonable si el equipo prefiere un estilo declarativo con clases y no necesitás el ecosistema de NgRx. - Elf: store reactivo minimalista y muy modular (
@ngneat/elfmás paquetes por feature: entidades, paginación, persistencia). Muy tree-shakeable: sumás sólo los add-ons que usás. Buena elección cuando querés un store liviano sin adoptar la ortodoxia Redux.
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:
- A11y (
@angular/cdk/a11y):FocusTrap,LiveAnnouncer,FocusMonitor,cdkTrapFocus. Accesibilidad de teclado y screen readers correcta sin escribirla a mano —notoriamente difícil de acertar en diálogos y menús. - Overlay (
@angular/cdk/overlay): posicionamiento de flotantes (tooltips, popovers, dropdowns) con estrategias de posición y scroll. Reemplaza a hacks deposition: absoluteque se rompen en los bordes del viewport. - Virtual scroll (
@angular/cdk/scrolling):cdk-virtual-scroll-viewportrenderiza sólo los ítems visibles. Convierte una lista de 10.000 filas de O(n) nodos en el DOM a O(ventana visible). Es la diferencia entre una tabla usable y una que congela el hilo principal. - Harnesses (
@angular/cdk/testing): API estable para testear componentes sin acoplarte al DOM interno. Los usaste en testing.
// 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
- PrimeNG: catálogo enorme (data tables, calendarios, charts). Aporta velocidad de desarrollo a costa de más peso y un estilo propio que hay que domar. Razonable para back-offices con muchos widgets.
- Spartan (spartan/ui): componentes estilo shadcn para Angular —“unstyled” con primitivas accesibles (muchas apoyadas en el CDK) y estilos con Tailwind que copiás a tu repo. Cero acoplamiento a una librería de estilos: el código de los componentes es tuyo. Encaja si querés control total del diseño y ya usás Tailwind.
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:
- Peso por lo que usás: ¿cuántos KB gzip suma después de tree-shaking para las piezas que realmente vas a usar? Compará contra tu bundle budget (cap. 7).
- Tree-shakeability: ESM,
sideEffects: false, exports granulares. Un default export monolítico es señal roja. - Mantenimiento: releases recientes, compatibilidad con la última major de Angular, tamaño de comunidad.
- Acoplamiento: ¿cuánto código tuyo toca su API? Preferí librerías que puedas aislar tras una fachada propia, así el reemplazo es local.
- Tamaño y nivel del equipo: una herramienta con curva alta (NgRx clásico) rinde en equipos grandes que necesitan convención; en un equipo chico, el boilerplate no se amortiza.
- Nativo primero: si Angular 22 lo resuelve (signals,
httpResource, Signal Forms,@defer,NgOptimizedImage), esa es tu línea base a batir.
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
- Antes de instalar, verificaste que Angular 22 no lo resuelva nativo (signals,
httpResource, Signal Forms,@defer). - Medís el peso de la librería en KB gzip después de tree-shaking, no por el tamaño en npm.
- Confirmaste que expone ESM con exports granulares y
sideEffects: false. - Para estado: usás service + signals salvo que haya complejidad compartida real; si escalás, elegís SignalStore antes que el Redux clásico salvo necesidad de su tooling.
- Separás estado de cliente (signals/store) de estado de servidor (
httpResourceo TanStack Query); no reimplementás caché a mano. - Aprovechás el CDK (a11y, overlay, virtual scroll, harnesses) aunque uses diseño propio.
- Elegís i18n según necesidad de switch dinámico:
@angular/localize(build-time) vs Transloco (runtime). - Compartís reglas de validación con esquemas (Zod) sólo cuando las reglas son ricas y viajan entre capas.
- Reemplazaste
momentpordate-fnsoIntl, y evitás importar lodash entero. - Aislás cada dependencia significativa tras una fachada propia para poder reemplazarla localmente.
- Revisaste el impacto en la superficie de seguridad y corrés
npm auditsobre las transitivas.
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 →.