Componentes con SOLID
Componentes con SOLID
Un componente Angular es, técnicamente, una clase con un template. Esa doble naturaleza lo convierte en el lugar donde más rápido se pudre un proyecto: es tentador meter en el mismo archivo el fetch de datos, la lógica de negocio, el estado de la UI, el formato de presentación y la navegación. El componente crece, cada cambio toca cinco cosas a la vez, y probarlo requiere levantar medio framework.
Este capítulo aplica SOLID a componentes con la API moderna de Angular 22. No es teoría: cada principio se traduce en una decisión concreta sobre dónde vive el estado, qué recibe un componente por sus input() y cómo se comunica hacia afuera. El objetivo es que un componente sea fácil de leer, barato de probar y estable frente al cambio.
SRP: un componente, una responsabilidad de UI
El Principio de Responsabilidad Única dice que una clase debe tener una sola razón para cambiar. En un componente esa razón es una responsabilidad de UI: presentar un fragmento de interfaz y reaccionar a la interacción del usuario. No es “gestionar usuarios”, no es “hablar con la API”, no es “orquestar el flujo de checkout”.
Cómo detectar un componente que hace de más
Las señales de que un componente violó SRP son observables a simple vista:
- Inyecta
HttpClientdirectamente en vez de un servicio de dominio. - Mezcla varias fuentes de verdad: hace
fetch, mantiene estado local, transforma para presentar y navega, todo en la misma clase. - Tiene métodos que no hablan de UI:
calcularImpuesto(),validarReglaDeNegocio(),mapearDtoAModelo(). - El template y la clase crecen juntos sin techo: 400 líneas de TypeScript y 300 de HTML.
- Cambia por razones distintas: un cambio de endpoint, un cambio de regla de negocio y un cambio de color lo tocan todo.
Mirá este componente que hace de todo:
// ANTIPATRÓN: componente-dios que mezcla 4 responsabilidades
@Component({
selector: 'app-user-panel',
template: `
@if (cargando) {
<p>Cargando…</p>
} @else {
<ul>
@for (u of usuariosFiltrados; track u.id) {
<li (click)="verDetalle(u.id)">
{{ u.nombre }} — {{ u.saldo | currency }}
</li>
}
</ul>
}
`,
})
export class UserPanel implements OnInit {
private http = inject(HttpClient);
private router = inject(Router);
usuarios: Usuario[] = [];
usuariosFiltrados: Usuario[] = [];
cargando = false;
filtro = '';
ngOnInit() {
this.cargando = true;
// 1) Acceso a datos (HTTP) dentro del componente
this.http.get<UsuarioDto[]>('/api/users').subscribe((dtos) => {
// 2) Mapeo DTO → modelo (lógica de dominio)
this.usuarios = dtos.map((d) => ({
id: d.id,
nombre: `${d.first_name} ${d.last_name}`,
// 3) Regla de negocio: saldo con recargo
saldo: d.balance * (d.is_premium ? 1 : 1.05),
}));
this.aplicarFiltro();
this.cargando = false;
});
}
aplicarFiltro() {
// 4) Presentación/filtrado
this.usuariosFiltrados = this.usuarios.filter((u) =>
u.nombre.toLowerCase().includes(this.filtro.toLowerCase()),
);
}
// 5) Navegación
verDetalle(id: string) {
this.router.navigate(['/users', id]);
}
}
Tiene cinco razones para cambiar. Además arrastra dos problemas de rendimiento: sin OnPush, cada evento del framework revisa este componente; y aplicarFiltro() recorre toda la lista en cada pulsación de tecla —O(n) por tecla, O(n·m) para m teclas— reconstruyendo un array nuevo que fuerza re-render del @for.
Separar responsabilidades
El refactor extrae cada razón de cambio a su lugar. El acceso a datos y la regla de negocio van a un servicio; el estado reactivo vive en el smart component; la presentación pura queda en un dumb component.
// Acceso a datos + mapeo aislados en un servicio de dominio
@Injectable({ providedIn: 'root' })
export class UsuariosService {
private http = inject(HttpClient);
// httpResource: estado declarativo (value/isLoading/error) como signals
private readonly recurso = httpResource<UsuarioDto[]>(() => '/api/users');
readonly usuarios = computed(() =>
(this.recurso.value() ?? []).map(mapearUsuario),
);
readonly cargando = this.recurso.isLoading;
}
// Regla de negocio como función pura y testeable en aislamiento
export function mapearUsuario(d: UsuarioDto): Usuario {
return {
id: d.id,
nombre: `${d.first_name} ${d.last_name}`,
saldo: d.balance * (d.is_premium ? 1 : 1.05),
};
}
// Smart component: orquesta estado y efectos, no presenta
@Component({
selector: 'app-user-panel',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-user-list
[usuarios]="usuariosFiltrados()"
[cargando]="service.cargando()"
(seleccionar)="verDetalle($event)"
/>
<input [value]="filtro()" (input)="filtro.set($any($event.target).value)" />
`,
})
export class UserPanel {
protected readonly service = inject(UsuariosService);
private readonly router = inject(Router);
protected readonly filtro = signal('');
// Derivación memoizada: sólo recalcula si cambian usuarios o filtro
protected readonly usuariosFiltrados = computed(() => {
const q = this.filtro().toLowerCase();
return this.service.usuarios().filter((u) =>
u.nombre.toLowerCase().includes(q),
);
});
protected verDetalle(id: string) {
this.router.navigate(['/users', id]);
}
}
El filtrado sigue siendo O(n) por evaluación, pero computed lo memoiza: sólo se recalcula cuando cambia filtro o usuarios, no en cada ciclo de detección de cambios. Y como veremos, la lista puede presentarse con un dumb component sin lógica alguna.
Smart / Dumb: dónde vive el estado
El patrón smart/dumb (también container/presentational) separa los componentes en dos categorías con contratos distintos:
| Smart (container) | Dumb (presentational) | |
|---|---|---|
| Responsabilidad | Estado, efectos, orquestación | Presentar datos, emitir intención |
| Conoce servicios | Sí (los inyecta) | No |
| Fuente de datos | signal/computed/resource | input() |
| Comunicación saliente | Navega, llama servicios | output() |
| Change detection | OnPush | OnPush |
| Testeabilidad | Media (mock de servicios) | Alta (sólo inputs/outputs) |
| Reutilización | Baja (atado al feature) | Alta (agnóstico del origen) |
La regla es simple: el estado y los efectos viven arriba (smart), la presentación vive abajo (dumb). Un dumb component es una función de sus inputs: dado el mismo input, produce el mismo DOM, sin efectos secundarios. Recibe datos por input() y comunica la intención del usuario por output(), sin decidir qué pasa después.
// Dumb component: presentación pura, sin servicios, sin estado propio
@Component({
selector: 'app-user-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (cargando()) {
<p>Cargando…</p>
} @else {
<ul>
@for (u of usuarios(); track u.id) {
<li (click)="seleccionar.emit(u.id)">
{{ u.nombre }} — {{ u.saldo | currency }}
</li>
} @empty {
<li>No hay usuarios.</li>
}
</ul>
}
`,
})
export class UserList {
readonly usuarios = input.required<Usuario[]>();
readonly cargando = input(false);
readonly seleccionar = output<string>();
}
Este componente no sabe de dónde vienen los usuarios ni qué pasa al hacer click. Lo podés usar en cualquier pantalla, montarlo en Storybook, o probarlo pasándole un array y verificando el output —sin HttpClient, sin Router, sin TestBed pesado. Esa es la ganancia: testeabilidad y reutilización crecen cuando el componente no conoce el mundo exterior.
API moderna de componentes
Angular 22 reemplaza los decoradores @Input()/@Output() por funciones que devuelven signals. No es sólo sintaxis: cambia el modelo mental y habilita OnPush sin fricción.
input(), input.required() y transform
input() declara una propiedad de entrada como InputSignal<T>. Se lee llamándola como función dentro de contextos reactivos (template, computed, effect):
export class Slider {
// Opcional con valor por defecto → InputSignal<number>
readonly value = input(0);
// Requerido: error en build si el padre no lo pasa
readonly max = input.required<number>();
// Con alias y transform (booleanAttribute/numberAttribute son built-in)
readonly disabled = input(false, { transform: booleanAttribute });
readonly label = input('', { alias: 'sliderLabel' });
}
Ventajas sobre @Input():
input.required()es un contrato verificado en compilación: te olvidaste de pasar un input obligatorio y el build falla, no explota en runtime.- Se integra con
computedsinngOnChanges: las derivaciones se recalculan solas cuando el input cambia. - No necesitás
OnChangespara reaccionar a cambios: usáseffectocomputed.
Computed inputs: derivar en vez de duplicar
Un antipatrón clásico es sincronizar estado derivado a mano en ngOnChanges. Con signals, lo derivado se declara con computed y se mantiene solo:
// ANTIPATRÓN: estado derivado sincronizado manualmente
export class Precio implements OnChanges {
@Input() base = 0;
@Input() iva = 0.21;
total = 0; // fuente de verdad duplicada, se desincroniza fácil
ngOnChanges() {
this.total = this.base * (1 + this.iva); // hay que acordarse de recalcular
}
}
// MEJOR: derivación declarativa, imposible de desincronizar
export class Precio {
readonly base = input(0);
readonly iva = input(0.21);
// Se recalcula solo cuando cambia base o iva; memoizado
readonly total = computed(() => this.base() * (1 + this.iva()));
}
computed es memoizado: si sus dependencias no cambiaron, devuelve el valor cacheado sin reejecutar la función. Elimina una fuente de verdad duplicada y una razón de bug.
output(): eventos hacia afuera
output() devuelve un OutputEmitterRef<T>. Emitís con .emit(); en el template el padre escucha con (nombre)="handler($event)":
export class ExpandablePanel {
readonly cerrado = output<void>();
readonly cambio = output<number>({ alias: 'valueChanged' });
cerrar() {
this.cerrado.emit();
}
}
A diferencia de @Output() = new EventEmitter(), output() no expone la maquinaria de RxJS al consumidor y deja claro que es un canal de salida, no un observable para suscribirse a discreción.
model(): two-way binding explícito
model() crea un input con su output de cambio asociado, habilitando la sintaxis [(value)]. Angular genera automáticamente el evento valueChange:
export class Slider {
readonly value = model(0); // habilita [(value)]="volumen" en el padre
incrementar() {
this.value.update((v) => v + 10); // notifica al padre
}
}
Usá model() sólo cuando el componente realmente posee y muta un valor compartido con el padre (un input de formulario, un slider). Para todo lo demás, input() + output() deja el flujo de datos más explícito.
OnPush por defecto, y por qué
Poné ChangeDetectionStrategy.OnPush en todos los componentes. Con la estrategia por defecto (Default), Angular revisa cada componente en cada ciclo de detección de cambios, disparado por cualquier evento del navegador, timer o promesa —re-evalúa todo el árbol aunque nada relevante haya cambiado.
Con OnPush, un componente sólo se re-revisa cuando:
- Cambia la referencia de un
input(). - Se dispara un
output()/evento originado en su template. - Un signal leído en su template cambia.
- Se marca explícitamente con
markForCheck()(raro con signals).
El resultado es que el trabajo de detección de cambios pasa de O(número de componentes del árbol) por evento a O(componentes efectivamente afectados). Con signals + OnPush, Angular puede además revisar sólo el sub-árbol que depende del signal que cambió. Es la base del rendimiento de runtime y prepara el terreno para zoneless (capítulo 6).
@Component({
selector: 'app-badge',
changeDetection: ChangeDetectionStrategy.OnPush, // no es opcional en código de calidad
template: `<span class="badge">{{ texto() }}</span>`,
})
export class Badge {
readonly texto = input.required<string>();
}
Nuevo control flow en templates
El control flow de bloque (@if/@for/@switch) reemplaza a *ngIf/*ngFor/ngSwitch. Es más rápido, no necesita importar directivas y trae track obligatorio.
@if / @else
@if (usuario(); as u) {
<app-perfil [usuario]="u" />
} @else if (cargando()) {
<app-spinner />
} @else {
<app-vacio />
}
La forma ; as u captura el valor —útil para no re-evaluar el signal ni lidiar con null dentro del bloque.
@for con track y @empty
track es obligatorio y es la palanca de rendimiento del bloque. Le dice a Angular cómo relacionar cada item con su nodo del DOM:
@for (u of usuarios(); track u.id) {
<li>{{ u.nombre }}</li>
} @empty {
<li>Sin resultados.</li>
}
track u.id(identidad estable): al reordenar o mutar la lista, Angular reutiliza los nodos existentes y aplica el mínimo de operaciones de DOM. Es O(cambios reales).track $index: sirve para listas estáticas o de primitivas sin id estable.- Trackear por referencia del objeto (lo que hacía
*ngForsintrackBy) fuerza a destruir y recrear nodos cuando cambia la referencia aunque el contenido sea el mismo: DOM thrashing, O(n) de creación en cada cambio.
Un track mal elegido es una de las causas más comunes de listas lentas: reconstruye el DOM entero en cada actualización en vez de parchear lo que cambió.
@switch
@switch (rol()) {
@case ('admin') { <app-admin /> }
@case ('editor') { <app-editor /> }
@default { <app-viewer /> }
}
Usa igualdad estricta (===), sin fallthrough. Comparado con [ngSwitch], no requiere importar directivas y el compilador lo optimiza mejor.
OCP y DIP en componentes
Los dos principios que más se ignoran en componentes son Abierto/Cerrado (extender sin modificar) e Inversión de Dependencias (depender de abstracciones, no de concreciones). Angular ofrece tres herramientas para cumplirlos: content projection, directivas composables e inyección de estrategias.
Content projection (ng-content): OCP en la estructura
Un componente que hardcodea su contenido interno está cerrado a la extensión: para agregar una variante, hay que modificarlo. ng-content invierte eso: el componente define la estructura (el “hueco”) y el consumidor proyecta el contenido.
// ANTIPATRÓN: card cerrada, cada variante requiere un @if más
@Component({
selector: 'app-card',
template: `
<div class="card">
@if (tipo() === 'usuario') { <app-user-header [u]="data()" /> }
@if (tipo() === 'producto') { <app-product-header [p]="data()" /> }
<!-- cada nuevo tipo obliga a modificar esta clase -->
</div>
`,
})
export class Card { /* ... */ }
// MEJOR: card abierta a extensión, cerrada a modificación
@Component({
selector: 'app-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<header class="card__header">
<ng-content select="[card-header]" />
</header>
<div class="card__body">
<ng-content />
</div>
</div>
`,
})
export class Card {}
// El consumidor extiende sin tocar Card
@Component({
template: `
<app-card>
<app-user-header card-header [u]="usuario()" />
<p>Contenido arbitrario aquí.</p>
</app-card>
`,
})
export class Pantalla {}
Card no conoce a UserHeader ni a ProductHeader. Agregar una variante nueva no toca Card: es OCP puro. Los select="[slot]" permiten múltiples huecos con nombre (multi-slot projection).
Directivas composables: comportamiento sin herencia
En vez de heredar de una clase base para compartir comportamiento (acoplamiento rígido, un solo padre), componés directivas standalone sobre el host. Cada directiva agrega una capacidad ortogonal:
@Directive({ selector: '[appAutofocus]' })
export class Autofocus {
private el = inject(ElementRef<HTMLElement>);
constructor() {
afterNextRender(() => this.el.nativeElement.focus());
}
}
// Se compone en el host sin herencia
@Component({
selector: 'app-search',
imports: [Autofocus],
hostDirectives: [Autofocus], // el comportamiento se agrega, no se hereda
template: `<input appAutofocus />`,
})
export class Search {}
hostDirectives permite adjuntar directivas al host de un componente y hasta exponer sus inputs/outputs, componiendo comportamiento en vez de heredarlo.
Inyección de estrategias: DIP
Cuando un componente necesita variar un comportamiento (formatear, ordenar, validar), en vez de un switch interno depende de una abstracción inyectada. El componente queda cerrado; la estrategia concreta se provee desde afuera.
// Abstracción (puerto)
export interface FormateadorPrecio {
formatear(valor: number): string;
}
export const FORMATEADOR_PRECIO = new InjectionToken<FormateadorPrecio>(
'FormateadorPrecio',
);
// El componente depende de la abstracción, no de una implementación
@Component({
selector: 'app-precio',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ formateado() }}</span>`,
})
export class Precio {
private fmt = inject(FORMATEADOR_PRECIO);
readonly valor = input.required<number>();
readonly formateado = computed(() => this.fmt.formatear(this.valor()));
}
// La implementación concreta se decide en el árbol de inyección
providers: [
{ provide: FORMATEADOR_PRECIO, useClass: FormateadorPesos },
];
Precio no conoce FormateadorPesos. Cambiar a FormateadorDolares o inyectar un mock en un test no toca el componente. Esto es DIP, y el capítulo 9 lo desarrolla en profundidad: Inyección de dependencias →.
Catálogo de antipatrones
- Componente-dios: fetch + estado + negocio + presentación + navegación en una clase. Múltiples razones de cambio, imposible de probar en aislamiento. Refactor: extraer servicio de dominio + smart/dumb.
- Lógica de negocio en el componente: reglas de precio, impuestos o validaciones de dominio en la clase del componente. Van a funciones puras o servicios; el componente sólo presenta. Una regla en el componente no se reutiliza fuera de esa pantalla y sólo se prueba levantando el componente.
- Mutar el
input():this.usuarios().push(nuevo)o reasignar propiedades del objeto recibido. Rompe el flujo de datos unidireccional y conOnPushproduce vistas inconsistentes porque la referencia no cambia. El input es de sólo lectura por diseño: emitís unoutput()y el dueño del estado lo actualiza. - Inyectar servicios concretos en dumb components: un componente de presentación que inyecta
HttpCliento un servicio específico pierde reutilización y testeabilidad. Los dumb reciben todo porinput(). - Over-nesting: árboles de 6-7 niveles de wrappers que sólo pasan inputs hacia abajo (prop drilling). Encarecen la detección de cambios y ofuscan el flujo. Aplaná con content projection o mové el estado a un servicio inyectable.
Defaultchange detection: dejar la estrategia por defecto hace que cada componente se revise en cada evento del framework.OnPushen todos.- Estado derivado sincronizado a mano: duplicar en una propiedad lo que se puede derivar con
computed. Fuente de bugs de desincronización. trackpor referencia u omitido mentalmente: aunque@forobliga a ponertrack, elegirtrack item(referencia) en vez detrack item.idreconstruye el DOM en cada cambio.
Flujo de datos unidireccional
El modelo mental que ata todo: los datos bajan por input(), los eventos suben por output(). El estado tiene un único dueño (el smart component o un servicio), y nadie modifica lo que le llega por input.
flowchart TD
subgraph Smart["Smart Component (estado + efectos)"]
S[signal / computed / resource]
SVC[Servicio de dominio]
end
subgraph Dumb["Dumb Components (presentación pura)"]
D1[UserList]
D2[UserRow]
end
SVC -->|inject| S
S -->|input| D1
D1 -->|input| D2
D2 -->|output: seleccionar| D1
D1 -->|output: seleccionar| S
S -->|actualiza estado / navega| SVC
classDef smart fill:#1e3a5f,stroke:#4a90d9,color:#fff
classDef dumb fill:#2d4a2b,stroke:#6ab04c,color:#fff
class S,SVC smart
class D1,D2 dumb
Los datos fluyen en una sola dirección (arriba → abajo por inputs); las intenciones del usuario viajan en la opuesta (abajo → arriba por outputs) hasta llegar al dueño del estado, que es el único que muta. Ningún dumb component modifica su input ni conoce el servicio. Este ciclo cerrado es lo que hace que el estado sea razonable: para entender por qué la UI muestra algo, sólo mirás quién es el dueño del signal, no una maraña de mutaciones dispersas.
sequenceDiagram
participant U as Usuario
participant D as UserList (dumb)
participant S as UserPanel (smart)
participant Svc as UsuariosService
U->>D: click en un item
D->>S: seleccionar.emit(id)
S->>Svc: (o) router.navigate / update signal
Svc-->>S: nuevo estado (signal)
S-->>D: nuevo input (referencia nueva)
D-->>U: re-render mínimo (OnPush + track)
Checklist
- Cada componente tiene una responsabilidad de UI; el fetch y las reglas de negocio viven en servicios/funciones puras.
- Distinguís smart (estado, efectos, inyecta servicios) de dumb (sólo
input()/output(), sin servicios). -
ChangeDetectionStrategy.OnPushen todos los componentes. - Usás
input()/input.required()/output()/model(); no quedan@Input()/@Output()decoradores. - El estado derivado se declara con
computed, no se sincroniza a mano enngOnChanges. - Control flow de bloque (
@if/@for/@switch) contrackpor identidad estable y@emptydonde aplique. - Ningún componente muta un
input(); el flujo de datos es unidireccional. - La extensión de estructura usa
ng-content(OCP); el comportamiento se compone con directivas/hostDirectives, no con herencia. - Los componentes dependen de abstracciones inyectadas (
InjectionToken), no de servicios concretos hardcodeados (DIP). - No hay componentes-dios, prop drilling profundo ni lógica de dominio en la clase del componente.
Con los componentes ya diseñados según SRP y con el estado empujado hacia arriba, la pregunta siguiente es cómo modelar ese estado sin que se vuelva un nudo. Eso es signals: Signals y estado reactivo →.