Saltar a contenido

Parte 2: Angular

Esta página es una parte de la guía de estudio; allí están el mapa general y la ruta recomendada. La Parte 1, sobre TypeScript, está en 01-typescript-intro. Proyecto: 02-bases/.

Desde aquí la guía sigue el curso de Angular. Los ejemplos están escritos y compilados con Angular 22, la versión actual. Las diapositivas del curso muestran Angular 20: donde algo cambió, la guía lo dice.


Qué es Angular

Angular es un framework opinionado: ya decidió por ti cómo se enruta, cómo se hacen los formularios, cómo se pide información a una API y cómo se organiza el código. Eso promueve orden y legibilidad, porque dos proyectos de Angular se parecen mucho entre sí.

Con los años se volvió más flexible. Antes todo componente tenía que declararse dentro de un módulo (NgModule); hoy los componentes son standalone: cada uno declara lo que necesita y los módulos son opcionales.

Decoradores

Un decorador es una etiqueta que empieza con @ y se pone encima de una clase (o de una propiedad) para decirle a Angular qué es y cómo usarla. La clase sola es TypeScript normal; el decorador es lo que la convierte en componente, servicio o directiva.

@Component({
  selector: 'app-hero',
  template: `<h1>{{ name }}</h1>`
})
export class Hero {
  name = 'Geralt'
}

Sin @Component, Hero es una clase cualquiera. Con él, Angular sabe que es una pieza de pantalla, con qué etiqueta HTML se usa (<app-hero>) y qué dibuja.

Decorador Qué le dice a Angular
@Component Es una pieza de pantalla, con su template
@Directive Agrega comportamiento a un elemento que ya existe
@Pipe Transforma un valor para mostrarlo ({{ price \| currency }})
@Injectable Es un servicio: Angular puede crearlo y entregarlo a quien lo pida
@Input / @Output Datos que entran al componente / eventos que salen

Por qué ayudan a leer el código: abres un archivo y, con sólo ver el decorador, ya sabes qué papel cumple esa clase.

Por dentro, un decorador es una función que recibe la clase y le adjunta información (metadata). Angular lee esa información al compilar.

Ojo: en el Angular actual varios decoradores de propiedad tienen un reemplazo en forma de función (input() y output() en lugar de @Input y @Output). Los de clase (@Component, @Injectable, @Directive, @Pipe) se siguen usando igual.


Mitos sobre Angular

Antes de empezar conviene despejar algunas ideas que se repiten mucho y que no son ciertas, o que ya dejaron de serlo.

Mito 1: «Angular es mejor (o peor) que React, Vue, Svelte o Solid»

Realidad: no, son herramientas distintas. Angular es un framework opinionado: ya trae resuelto el enrutado, los formularios y las peticiones HTTP, y te dice cómo organizar el código. React es una librería, y Vue, Svelte y Solid son frameworks más livianos: dan más libertad, pero dejan más decisiones en manos del equipo. Ninguno es mejor en general; depende de lo que necesite tu proyecto.

Ojo: la herramienta no garantiza el orden. La estructura de carpetas y la disciplina dependen del equipo, y también hay proyectos de Angular desordenados.

Mito 2: «Hay que volver a aprender Angular cada 6 meses»

Realidad: no. Angular publica una versión mayor cada seis meses, pero la mayoría de los cambios no son disruptivos. Y cuando lo son, no hay que aprender de nuevo todo el framework: lo que ya sabes sigue sirviendo. Además, ng update hace buena parte de la migración por ti, la documentación oficial es muy buena y la comunidad es grande, así que ponerse al día cuesta poco.

Mito 3: «Las aplicaciones de Angular son muy pesadas»

Realidad: cada versión reduce el bundle (lo que el navegador tiene que descargar). Hoy el peso depende sobre todo de ti y de las buenas prácticas que apliques.

Para que pese menos:

  • Lazy loading de rutas. Cada pantalla se descarga sólo cuando alguien la visita, en lugar de bajar todo al entrar: { path: 'heroes', loadComponent: () => import('./heroes/heroes').then(m => m.Heroes) }.
  • @defer en el template. Lo mismo, pero para un pedazo de pantalla: un gráfico pesado se descarga recién cuando aparece en el área visible.
  • Cuidar las dependencias. Cada librería que instalas viaja al navegador. Importa sólo lo que usas y desconfía de las librerías enormes para tareas pequeñas.
  • Componentes compartidos. Un botón o una tarjeta se escriben una vez y se reutilizan; copiar y pegar el mismo código en diez pantallas lo descarga diez veces.
  • Budgets. En angular.json se define un tamaño máximo; si el build lo supera, avisa o falla. Así el peso no crece sin que nadie lo note.

Para que no se vuelva lenta con el uso (no cambia el peso, sí la experiencia):

  • Cerrar las suscripciones. Una suscripción abierta sigue trabajando aunque el componente ya no esté en pantalla, y la memoria nunca se libera. Se evita con el pipe async en el template o con takeUntilDestroyed().
  • Respetar el ciclo de vida. Lo que el componente abre (un temporizador, un listener) lo cierra al destruirse (ngOnDestroy o DestroyRef).
  • Signals u OnPush. Angular revisa sólo los componentes cuyos datos cambiaron, en lugar de revisar todos.
  • track en @for. Al cambiar una lista, Angular reutiliza los elementos que ya estaban en pantalla en lugar de dibujarlos de nuevo.

Mito 4: «Angular no es SEO friendly»

Realidad: sí lo es. Angular puede armar la página en el servidor (SSR) o dejarla armada de antemano al compilar (prerender). En los dos casos Google recibe HTML con el contenido ya puesto, no una página vacía que depende de JavaScript. Después, en el navegador, Angular «hidrata» esa página: la vuelve interactiva sin dibujarla otra vez.

De dónde viene el mito. Una aplicación clásica de Angular se dibuja en el navegador (CSR: client-side rendering, «renderizado del lado del cliente»). El servidor manda un HTML casi vacío, apenas <app-root></app-root> y los scripts, y el contenido aparece recién cuando el JavaScript se descarga y corre. Un robot que lee ese HTML sin ejecutar JavaScript no ve nada. Google sí ejecuta JavaScript, pero lo hace más tarde y con límites; las vistas previas de redes sociales y apps de mensajería directamente no lo ejecutan.

Las tres formas de dibujar la página:

CSR         servidor manda HTML vacío ------> el navegador baja el JS -> el JS dibuja la página
SSR         servidor dibuja en cada visita -> manda HTML completo ----> hidratación
Prerender   se dibuja una vez al compilar --> se guarda como .html ---> hidratación
  • SSR (server-side rendering, «renderizado del lado del servidor»): sirve cuando el contenido cambia seguido o depende de quién visita (una tienda, un perfil público).
  • Prerender (también llamado SSG): sirve cuando el contenido casi no cambia (un blog, una página de presentación). Es lo más rápido, porque el HTML ya está hecho.
  • CSR: alcanza para pantallas detrás de un login, donde el SEO no importa.

Qué es la hidratación. El HTML que llega del servidor se ve, pero todavía no responde: los botones no hacen nada. Hidratar es el momento en que Angular, ya en el navegador, reconoce ese HTML que está en pantalla y le conecta los eventos y el estado. No lo borra ni lo dibuja otra vez; antes de la versión 16 sí lo hacía, y la página parpadeaba.

Un beneficio extra. Como el contenido llega ya dibujado, la página se ve antes. Google también mide esa velocidad para posicionar.

Ojo: hay que activarlo (ng new --ssr al crear el proyecto, o ng add @angular/ssr después). Sin eso, la aplicación se dibuja sólo en el navegador y el HTML inicial llega casi vacío.

Mito 5: «Angular no soporta Redux»

Realidad: sí lo soporta. Redux es una forma de guardar todo el estado de la aplicación en un solo lugar, y en Angular se usa con librerías como NgRx o NGXS. Además, hoy Angular trae Signals, una opción nativa y más simple para manejar estado, que alcanza para muchas aplicaciones.

Mito 6: «En Angular sólo puedo usar TypeScript»

Realidad: también puedes usar librerías y código JavaScript, porque al final TypeScript se convierte en JavaScript para correr en el navegador.

  • Si una librería JavaScript no trae tipos, se instalan aparte con @types/nombre-libreria. Hoy la mayoría ya los incluye.
  • Puedes escribir archivos .js propios activando allowJs en tsconfig.json.

Es posible, pero se recomienda TypeScript: sin tipos pierdes el aviso de errores antes de ejecutar (ver la Introducción de la Parte 1).

Resumen

Angular no es mejor ni peor que las demás opciones, no hay que reaprenderlo cada seis meses, no es pesado por obligación, sí sirve para SEO, sí soporta Redux y sí acepta JavaScript. Casi todos estos mitos vienen de versiones antiguas del framework.


Módulo A1: Angular como framework y sus bloques fundamentales

Fuente: diapositivas del curso, sección 1 de Angular.

El problema que resuelve

Con TypeScript solo (Parte 1) puedes escribir lógica, pero una aplicación real necesita mucho más: dibujar pantallas que se actualizan solas, cambiar de página, pedir datos a un servidor, compartir información entre pantallas. Si cada equipo arma eso a su manera, juntando librerías sueltas, cada proyecto termina siendo distinto. Angular trae todas esas piezas y una forma acordada de usarlas.

Un framework: todo viene en la caja

Una librería es una herramienta que llamas cuando la necesitas (por ejemplo, una para formatear fechas). Un framework es la estructura completa de la casa: tú pones el contenido, pero él decide dónde va cada cosa y cuándo se ejecuta.

Angular es un framework "con baterías incluidas":

Necesidad Qué trae Angular Paquete
Reactividad Signals: valores que avisan cuando cambian @angular/core
Gestor de estado Servicios con signals (sin librería extra) @angular/core
Enrutamiento Cambiar de página sin recargar el navegador @angular/router
Peticiones HTTP HttpClient para hablar con un servidor @angular/common
Directivas Cambiar el comportamiento de elementos HTML @angular/core
Formularios Validación y manejo de formularios @angular/forms
Herramientas Crear, ejecutar, probar y actualizar el proyecto @angular/cli

Comparación: React es una librería para dibujar la interfaz; para rutas, estado o formularios eliges otras librerías. Angular ya trae una opción oficial para cada cosa. Ni uno ni otro es mejor en general: ver Mitos sobre Angular.

Multiplataforma

Con el mismo conocimiento puedes llegar a varias plataformas:

Web: tres formas de entregar la página.

Sigla Nombre Quién arma el HTML Cuándo conviene
SPA Aplicación de una sola página (Single Page App) El navegador, con JavaScript Paneles internos, apps detrás de un login
SSR Renderizado en el servidor (Server-Side Rendering) El servidor, en cada visita Páginas públicas que deben aparecer en buscadores
SSG Generación estática (Static Site Generation) El build, una sola vez Contenido que casi no cambia: blog, documentación
  • SPA: el servidor envía un HTML casi vacío y el JavaScript dibuja todo. Es la opción por defecto de ng new.
  • SSR: el usuario recibe la página ya armada, así que ve contenido antes. Después Angular "despierta" esa página para que responda a clics (hidratación).
  • SSG (en Angular se llama prerender): el HTML se genera al hacer el build y se sirve como un archivo fijo, muy rápido.

Se pueden combinar: cada ruta elige su forma (ver Rutas).

Móvil y escritorio:

Plataforma Herramienta Cómo funciona
Móvil Ionic Tu app web corre dentro de una app nativa, con componentes móviles
Móvil NativeScript Usa los controles nativos del teléfono en lugar de HTML
Escritorio Electron Empaqueta tu app web con un navegador propio, como VS Code

Versiones: una mayor cada seis meses

Angular publica una versión mayor (20, 21, 22…) más o menos cada seis meses:

Versión Publicada
20 mayo de 2025
21 noviembre 2025
22 junio de 2026
  • Todos los paquetes @angular/* llevan el mismo número. Por eso la diapositiva marca todo con 20.0.0: router, HTTP, formularios y núcleo avanzan juntos.
  • El número tiene tres partes: 22.2.1 = mayor (puede romper cosas), menor (agrega cosas sin romper), parche (corrige errores).
  • Cada versión mayor recibe soporte unos 18 meses: seis de desarrollo activo y doce de soporte a largo plazo (LTS).
  • Lo viejo no desaparece de golpe: primero se marca como obsoleto (deprecated) y se elimina varias versiones después. Por ejemplo, *ngIf quedó obsoleto en la 20 y en la 22 todavía existe.
  • Para actualizar: ng update @angular/core @angular/cli, de una versión mayor por vez, siguiendo la guía oficial (angular.dev/update-guide).

Qué cambió entre la versión de las diapositivas (20) y la actual (22), visto en un proyecto nuevo de ng new:

  • Ya no usa zone.js para detectar cambios: se apoya en los signals (explicado en Zoneless).
  • Los archivos no llevan sufijo: app.ts en lugar de app.component.ts.
  • Las pruebas usan Vitest en lugar de Karma.

Los seis bloques fundamentales

┌─────────────┬──────────┐
│ Componentes │  Rutas   │   lo que ves y cómo navegas
├─────────────┼──────────┤
│ Directivas  │ Servicios│   comportamiento y lógica compartida
├─────────────┼──────────┤
│ Módulos     │  Pipes   │   organización y formato de datos
└─────────────┴──────────┘

Componentes

Un componente es una pieza de la pantalla. Tiene tres partes:

Parte Qué es Obligatoria
Lógica (TS) La clase: datos y métodos Sí
Plantilla (HTML) Lo que se dibuja Sí
Estilos (CSS, SCSS) Cómo se ve; sólo afectan a este componente No
import { Component, input, output } from '@angular/core'
import { UpperCasePipe } from '@angular/common'
import type { Hero } from './hero'

@Component({
  selector: 'app-hero-card',
  imports: [UpperCasePipe],
  template: `
    <article>
      <h3>{{ hero().name | uppercase }}</h3>
      <p>Poder: {{ hero().power }}</p>
      <button (click)="removed.emit(hero().id)">Eliminar</button>
    </article>
  `,
})
export class HeroCard {
  readonly hero = input.required<Hero>()
  readonly removed = output<number>()
}
  • @Component le dice a Angular que la clase es una pieza de pantalla (Módulo 10).
  • selector: 'app-hero-card' es la etiqueta con la que se usa: <app-hero-card />.
  • input.required<Hero>(): el dato que entra desde el componente padre.
  • output<number>(): el aviso que sale hacia el padre.
  • La plantilla puede ir en el mismo archivo (template) o en uno aparte (templateUrl: './hero-card.html'), igual que los estilos.

De lo micro a lo macro. Una aplicación es un árbol de componentes. Los pequeños se combinan en otros más grandes, hasta formar una página:

App                          ← la aplicación entera
└── HeroesPage               ← una página (la elige la ruta)
    ├── SearchBox            ← una sección
    └── HeroList
        └── HeroCard (×N)    ← una tarjeta, repetida por cada héroe
            └── button       ← un elemento HTML

Es composición (Módulo 8): cada componente tiene otros adentro.

Componentes inteligentes y presentacionales (smart y dumb).

Tipo Qué hace Ejemplo
Inteligente (smart, contenedor) Sabe de dónde salen los datos: usa servicios, decide qué hacer HeroesPage
Presentacional (dumb) Sólo muestra lo que recibe y avisa lo que pasa HeroCard
@Component({
  selector: 'app-heroes-page',
  imports: [HeroCard],
  template: `
    @for (hero of store.all(); track hero.id) {
      <app-hero-card [hero]="hero" (removed)="store.remove($event)" />
    }
  `,
})
export class HeroesPage {
  protected readonly store = inject(HeroStore)
}

HeroCard no sabe que existe HeroStore: recibe un héroe y avisa "me pidieron eliminar el 2". HeroesPage decide qué hacer con ese aviso. Ventajas: HeroCard se puede reutilizar en otra pantalla y probar sin servicios. La regla práctica: pocas piezas inteligentes arriba (normalmente las páginas) y muchas presentacionales abajo.

Rutas

Las rutas relacionan una dirección (/heroes) con un componente. Al cambiar de ruta, Angular cambia la página sin recargar el navegador.

// app.routes.ts
export const routes: Routes = [
  { path: '', component: HomePage },
  {
    path: 'heroes',
    loadComponent: () => import('./heroes/heroes-page').then(m => m.HeroesPage),
    canActivate: [authGuard],
  },
  { path: '**', redirectTo: '' },
]
<!-- app.html: aquí se dibuja la página de la ruta actual -->
<nav><a routerLink="/heroes">Héroes</a></nav>
<router-outlet />

Lo que dicen las diapositivas, en el Angular actual:

  • Separar lógica: cada página es su propio componente, y con loadComponent su código se descarga sólo cuando alguien entra a esa ruta (carga bajo demanda, lazy loading). En el build se ve como un archivo aparte.
  • Control de acceso: un guard decide si se puede entrar. Hoy es una función simple:
export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService)
  const router = inject(Router)
  return auth.isLoggedIn() ? true : router.parseUrl('/login')
}

Ojo: un guard es comodidad para el usuario, no seguridad. El código llega al navegador igual; los datos se protegen en el servidor. - Estrategias de renderizado: con SSR activo, cada ruta puede elegir si se arma en el servidor, se genera en el build o se arma en el navegador (RenderMode.Server, RenderMode.Prerender, RenderMode.Client, en el archivo app.routes.server.ts).

Legacy: antes los guards eran clases que implementaban CanActivate, y la carga bajo demanda se hacía con módulos (loadChildren apuntando a un NgModule). Hoy se usan funciones y loadComponent.

Directivas

Una directiva cambia el comportamiento o el aspecto de un elemento HTML. Hay tres tipos:

Tipo Qué hace Hoy se usa Legacy
Componente Una directiva con su propia plantilla @Component —
Atributo Cambia aspecto o comportamiento [class.x], [style.x], directivas propias ngClass, ngStyle (siguen válidas)
Estructural Agrega o quita elementos de la página Control flow: @if, @for, @switch *ngIf, *ngFor, *ngSwitch (obsoletas)

Control flow: lo nuevo. Desde Angular 17 la plantilla tiene bloques propios, sin importar nada:

@if (store.total() === 0) {
  <p>No hay héroes.</p>
} @else {
  <p>Hay {{ store.total() }} héroes.</p>
}

@for (hero of store.all(); track hero.id) {
  <app-hero-card [hero]="hero" />
} @empty {
  <p>Lista vacía.</p>
}
  • track hero.id es obligatorio: le dice a Angular cómo reconocer cada elemento para no redibujar toda la lista cuando algo cambia.
  • @empty muestra algo cuando la lista está vacía.
  • @switch elige entre varios casos, y @defer carga una parte de la página más tarde (por ejemplo, cuando aparece en pantalla).

Legacy: *ngIf, *ngFor y *ngSwitch están marcadas como obsoletas desde Angular 20. Las vas a ver en proyectos existentes: <li *ngFor="let hero of heroes">. En código nuevo, usa @if y @for. Angular trae una migración automática: ng generate @angular/core:control-flow.

Atributo: clases y estilos. Para una sola clase o estilo, la forma recomendada es el enlace directo:

<small [class.strong]="hero.power > 90">…</small>
<div [style.width.px]="size">…</div>

ngClass y ngStyle siguen funcionando y no están obsoletas, pero rara vez hacen falta.

Directiva propia:

@Directive({
  selector: '[appHighlight]',
  host: {
    '[style.backgroundColor]': 'color()',
  },
})
export class Highlight {
  readonly color = input('yellow', { alias: 'appHighlight' })
}
<h2 appHighlight="lightyellow">Héroes</h2>

host conecta propiedades del elemento con la directiva. Antes se hacía con los decoradores @HostBinding y @HostListener (legacy).

Servicios

Un servicio es una clase que guarda lógica o datos que varios componentes necesitan. Los componentes se ocupan de mostrar; los servicios, de saber.

Para qué Ejemplo
Gestión de datos Guardar la lista de héroes y modificarla
Reutilizar código Una sola función de formato usada en diez pantallas
Inyección de dependencias Angular crea el servicio y se lo entrega a quien lo pida

Inyección de dependencias: el componente no hace new HeroStore(). Lo pide con inject(HeroStore) y Angular se lo entrega. Con providedIn: 'root' hay una sola instancia para toda la aplicación, así que todos los componentes ven los mismos datos.

Gestión de estado con un servicio y signals:

@Injectable({ providedIn: 'root' })
export class HeroStore {
  private readonly heroes = signal<Hero[]>([
    { id: 1, name: 'Geralt', power: 90 },
    { id: 2, name: 'Ciri', power: 95 },
  ])

  readonly all = this.heroes.asReadonly()
  readonly total = computed(() => this.heroes().length)

  add(name: string) {
    this.heroes.update(list => [...list, { id: Date.now(), name, power: 50 }])
  }

  remove(id: number) {
    this.heroes.update(list => list.filter(hero => hero.id !== id))
  }
}
  • El signal que se puede modificar es privado. Afuera sólo se expone una versión de lectura (asReadonly()).
  • Los cambios pasan sólo por métodos (add, remove). Así hay un solo lugar donde mirar cuando algo cambia mal. Es encapsulación (Módulo 8).
  • computed calcula valores derivados (el total) y se actualiza solo.
  • update crea un array nuevo en vez de modificar el viejo. Angular detecta el cambio porque la referencia es distinta (ver Pipes).

Para casos simples alcanza con esto. Cuando el estado crece mucho, existen librerías como NgRx (con su SignalStore), que siguen la misma idea con más reglas.

Fachada (facade). Cuando una pantalla necesita hablar con varios servicios, una fachada los reúne detrás de una sola clase:

@Injectable({ providedIn: 'root' })
export class HeroesFacade {
  private readonly store = inject(HeroStore)
  private readonly notifications = inject(Notifications)

  readonly heroes = this.store.all
  readonly total = this.store.total

  recruit(name: string) {
    this.store.add(name)
    this.notifications.show(`${name} se unió al grupo`)
  }
}

El componente sólo conoce HeroesFacade y sus acciones con nombre de negocio (recruit). Si mañana los héroes vienen de un servidor, cambia la fachada y no los componentes. Es como la recepción de un hotel: pides "una habitación" y no necesitas hablar con limpieza, cocina y reservas por separado.

Módulos

Un NgModule agrupaba piezas relacionadas (componentes, directivas, pipes) y decía qué se podía usar fuera:

@NgModule({
  declarations: [OldList],
  imports: [CommonModule],
  exports: [OldList],
})
export class LegacyModule {}

Las diapositivas le dan tres funciones: organizar la aplicación, encapsular dependencias y facilitar la carga bajo demanda. Hoy las tres se resuelven de otra forma:

Función Con NgModule (legacy) Hoy, con standalone
Organizar Un módulo por funcionalidad Carpetas por funcionalidad
Encapsular dependencias imports y exports del módulo Cada componente declara sus imports
Carga bajo demanda loadChildren con un módulo loadComponent o loadChildren con un archivo de rutas
Arrancar la app AppModule y bootstrapModule bootstrapApplication(App, appConfig)

Componentes standalone. Cada componente dice qué necesita en su propio imports, sin un módulo intermedio. Es más fácil de leer: abres el archivo y ves todas sus dependencias. Desde Angular 19, todo componente es standalone por defecto; para usar el estilo antiguo hay que escribir standalone: false.

Legacy: vas a encontrar NgModule en proyectos existentes. Los componentes standalone pueden importar un NgModule y al revés, así que se migra de a poco. Angular trae una migración: ng generate @angular/core:standalone.

No confundas estos módulos con los módulos de JavaScript del Módulo 7 (import/export entre archivos). Esos siguen siendo la base de todo; lo que pasó de moda es NgModule.

Pipes

Un pipe transforma un valor sólo para mostrarlo, sin cambiar el dato original. Se escribe con | en la plantilla:

{{ hero.name | uppercase }}            <!-- GERALT -->
{{ price | currency: 'EUR' }}          <!-- €1,500.00 -->
{{ today | date: 'dd/MM/yyyy' }}       <!-- 06/10/2026 -->
{{ ratio | percent }}                  <!-- 75% -->

Otros que vienen con Angular: lowercase, titlecase, number, json, slice, keyvalue y async.

Pipe propio:

@Pipe({ name: 'powerLevel' })
export class PowerLevelPipe implements PipeTransform {
  transform(power: number): string {
    if (power >= 90) return 'Legendario'
    if (power >= 70) return 'Fuerte'
    return 'Normal'
  }
}
{{ hero.power | powerLevel }}   <!-- Legendario -->

Puros e impuros:

Tipo Cuándo se vuelve a ejecutar Costo
Puro (por defecto) Sólo si el valor de entrada cambia Bajo
Impuro (pure: false) En cada revisión de cambios de la página Alto

Un pipe puro recuerda el último resultado: si recibe el mismo valor, no recalcula. Eso es la "optimización de rendimiento" de la diapositiva.

La trampa: con un array u objeto, "cambiar" significa ser otro array. Si haces heroes.push(nuevo), es el mismo array con un elemento más, y un pipe puro no se entera. Por eso los servicios usan update(list => [...list, nuevo]): crea un array nuevo.

Un pipe impuro sí se entera de un push, pero se ejecuta muchísimas veces.

Ordenar y filtrar. La diapositiva lo menciona, pero hacerlo con pipes es una mala costumbre: necesitarían ser impuros para ver cambios internos y se ejecutarían todo el tiempo. Angular no trae pipes para filtrar u ordenar justo por eso. Lo recomendado es un computed, que sólo recalcula cuando cambia la lista:

protected readonly sorted = computed(() =>
  [...this.facade.heroes()].sort((a, b) => a.name.localeCompare(b.name)),
)

Usa pipes para formatear (fechas, monedas, textos) y computed para ordenar, filtrar o calcular.

Errores comunes

  • Poner lógica de negocio en los componentes en vez de en servicios.
  • Hacer que un componente presentacional inyecte servicios: deja de ser reutilizable.
  • Escribir *ngIf o *ngFor en código nuevo. Usa @if y @for.
  • Olvidar track en @for, o usar track $index cuando la lista cambia de orden: Angular redibuja de más.
  • Exponer el signal modificable de un servicio: cualquier componente puede cambiar el estado sin pasar por los métodos.
  • Modificar un array con push y esperar que un pipe puro o un computed se actualice.
  • Confiar en un guard como seguridad: los datos se protegen en el servidor.
  • Confundir NgModule con los módulos de JavaScript.

Resumen

Angular es un framework: trae reactividad, rutas, HTTP, formularios y herramientas, y saca una versión mayor cada seis meses con todos sus paquetes en el mismo número. Se construye con seis bloques. Los componentes dibujan la pantalla y se combinan de lo pequeño a lo grande; los inteligentes usan servicios y los presentacionales sólo reciben y avisan. Las rutas cambian de página, cargan código bajo demanda y controlan el acceso. Las directivas cambian elementos; el control flow (@if, @for) reemplaza a *ngIf y *ngFor. Los servicios guardan lógica y estado, con signals y, si hace falta, una fachada. Los NgModule quedaron como legacy frente a los componentes standalone. Los pipes formatean valores; para ordenar o filtrar, mejor computed.

Preguntas de entrevista (senior)

1. ¿SPA, SSR o SSG? ¿Cómo decides?

Respuesta:

Según quién necesita ver la página y qué tan seguido cambia. Una app detrás de un login (un panel de administración) no necesita aparecer en buscadores: SPA es lo más simple. Páginas públicas con datos que cambian (un catálogo con precios) se benefician de SSR: el usuario y los buscadores reciben contenido de inmediato. Contenido que casi no cambia (documentación, una landing) va mejor con SSG (prerender): se genera una vez y se sirve como archivo fijo, sin costo de servidor por visita. En Angular no hay que elegir para toda la app: cada ruta define su forma. El costo de SSR es operar un servidor y cuidar el código que sólo funciona en el navegador (window, localStorage).

2. ¿Por qué separar componentes inteligentes y presentacionales?

Respuesta:

Porque separa qué se muestra de de dónde salen los datos. Un componente presentacional recibe datos por input() y avisa por output(): se puede reutilizar en otra pantalla, probar pasándole datos de ejemplo, y mostrar en un catálogo de componentes sin levantar servicios. El inteligente concentra la conexión con servicios, así que cuando cambia la fuente de datos se toca un solo lugar. No es una regla absoluta: un componente que sólo se usa una vez y es pequeño puede inyectar un servicio sin problema. El riesgo contrario es pasar datos por cinco niveles de input(); ahí conviene que un componente intermedio inyecte el servicio.

3. ¿Qué ganas al pasar de *ngIf/*ngFor a @if/@for?

Respuesta:

Varias cosas. No hay que importar nada: los bloques son parte de la plantilla. @for exige track, así que nadie olvida decirle a Angular cómo reconocer cada elemento, que era un problema de rendimiento frecuente con *ngFor. Trae @empty y @else sin trucos con ng-template. Funciona mejor con los tipos: dentro de @if (hero(); as h), Angular sabe que h existe. Y *ngIf, *ngFor y *ngSwitch están obsoletas desde la 20, así que el código nuevo no debería usarlas. Para proyectos existentes hay una migración automática.

4. ¿Cuándo alcanza con un servicio con signals y cuándo usarías NgRx?

Respuesta:

Un servicio con un signal privado, métodos para modificarlo y computed para lo derivado alcanza para la mayoría de las apps: es simple y no agrega dependencias. Una librería como NgRx tiene sentido cuando el estado es grande y lo tocan muchas partes, cuando el equipo necesita reglas estrictas sobre cómo se modifica, o cuando hacen falta herramientas para ver la historia de cambios. La señal de alarma es repetir el mismo patrón a mano en muchos servicios. Antes de saltar a una librería, conviene ordenar lo que hay: estado privado, cambios sólo por métodos y una fachada por funcionalidad.

5. Un proyecto grande usa NgModule. ¿Lo migras a standalone?

Respuesta:

Sí, pero de a poco y sin frenar el trabajo. Los dos estilos conviven: un componente standalone puede importar un NgModule y un NgModule puede importar un componente standalone. Angular trae una migración (ng generate @angular/core:standalone) que se ejecuta por partes: convertir componentes, quitar módulos que ya no hacen falta y pasar el arranque a bootstrapApplication. Conviene empezar por las piezas compartidas (botones, tarjetas) y seguir por funcionalidad, con las pruebas pasando en cada paso. Lo que se gana: dependencias visibles en cada componente, menos archivos y carga bajo demanda más fina.

6. Un pipe puro no se actualiza después de agregar un elemento a la lista. ¿Por qué y cómo lo resuelves?

Respuesta:

Porque un pipe puro sólo se vuelve a ejecutar si el valor de entrada es otro. Con heroes.push(nuevo) el array es el mismo (la misma referencia), así que para Angular no cambió nada. La solución correcta es no modificar el array: crear uno nuevo ([...heroes, nuevo], o signal.update(...) en un servicio). Marcar el pipe como impuro "lo arregla", pero lo ejecuta en cada revisión de cambios y puede volver lenta la pantalla. Si el pipe ordena o filtra, lo mejor es reemplazarlo por un computed.

7. ¿Cómo actualizas un proyecto de Angular 18 a la 22?

Respuesta:

De una versión mayor por vez: 18 → 19 → 20 → 21 → 22. Para cada salto se consulta la guía oficial (angular.dev/update-guide), se ejecuta ng update @angular/core@19 @angular/cli@19 (y así con cada número), se revisan los cambios que aplicaron las migraciones automáticas y se corren las pruebas antes del siguiente salto. Las librerías de terceros también tienen que soportar cada versión, así que conviene revisarlas antes de empezar. Saltar varias versiones juntas mezcla todos los cambios y hace muy difícil saber qué rompió qué.


Módulo A2: Crear un proyecto con ng new

Proyecto: 02-bases/, generado con Angular CLI 22.

El problema que resuelve

Un proyecto Angular necesita muchas piezas configuradas: el compilador, el servidor para desarrollar, las pruebas, las rutas, el formato del código. Armar todo a mano llevaría horas, y cada persona lo haría distinto. El comando ng new lo genera en un minuto, con una configuración que ya es la recomendada.

Antes de empezar

  • Node.js: Angular 22 pide Node 22.22.3 o superior de la rama 22, 24.15.0 o superior de la 24, o la 26 en adelante.
  • El CLI (Command Line Interface, la herramienta de línea de comandos de Angular) se instala una vez en tu máquina:
npm install -g @angular/cli
  • Dentro de un proyecto, ng usa la versión del proyecto. Si tienes el CLI 22.0.0 instalado en general, pero el proyecto trae el 22.2.2 en su node_modules, al ejecutar ng dentro del proyecto se usa el 22.2.2. Así cada proyecto se queda con su versión.

El comando

ng new bases

Crea una carpeta llamada bases con el proyecto adentro.

  • El nombre bases es el nombre del proyecto: aparece en package.json y en angular.json.
  • La carpeta se puede llamar distinto con --directory: ng new bases --directory 02-bases. En este repositorio la carpeta lleva número (02-bases) para quedar ordenada junto a 01-typescript-intro, y el proyecto se llama bases.
  • Se ejecuta desde la raíz del repositorio, para que la carpeta nueva quede al lado de las otras.

Para ver qué haría sin crear nada, agrega --dry-run. Muestra la lista de archivos y no escribe ninguno. Es útil para probar opciones.

El flujo: qué pasa al ejecutarlo

ng new bases
  │
  ├─ 1. Preguntas          (las que no pasaste como opciones)
  ├─ 2. Crea los archivos  (verás una línea CREATE por cada uno)
  ├─ 3. Instala paquetes   (npm install)
  └─ 4. Git                (sólo si la carpeta no está ya dentro de un repo)
  1. Preguntas. Si ya diste una respuesta con una opción (--style=css), esa pregunta no se hace.
  2. Archivos. El CLI escribe la estructura del proyecto (la tienes más abajo).
  3. Instalación. Ejecuta npm install y crea node_modules/ y package-lock.json. Se evita con --skip-install.
  4. Git. Si la carpeta no está dentro de un repositorio, ejecuta git init y hace un primer commit. Si ya está dentro de uno, como 02-bases dentro de angular-ufh, avisa que ya hay control de versiones y no hace nada. Por eso aquí no quedó un repositorio anidado. Se evita siempre con --skip-git.

Las preguntas y qué elegimos

Pregunta Opciones Elegimos
Nombre del proyecto El que quieras bases
Sistema de estilos CSS, Tailwind CSS, Sass (SCSS), Sass (indentado), Less CSS
Renderizado en servidor (SSR) y estático (SSG) Sí o no No: SPA
Herramientas de IA (se pueden marcar varias) Ninguna, Agents.md, Claude, Cursor, Gemini, GitHub Copilot, JetBrains AI, Windsurf Agents.md, Claude y GitHub Copilot

Sistema de estilos. Define el tipo de archivo de estilos de cada componente: app.css con CSS, app.scss con Sass. CSS es lo más simple y no necesita herramientas extra. Sass y Less agregan variables y anidación; Tailwind es otra forma de trabajar, con clases ya hechas. Se puede cambiar después, pero a mano.

SSR/SSG. Contestar no deja una SPA, la forma por defecto (ver Módulo A1). Contestar sí agrega cuatro archivos (main.server.ts, server.ts, app.config.server.ts y app.routes.server.ts), paquetes de servidor en package.json y cambios en angular.json. Si empiezas sin SSR y lo necesitas más tarde, se agrega con ng add @angular/ssr.

Herramientas de IA. Cada asistente lee un archivo con instrucciones del proyecto. Angular genera ese archivo con su lista de buenas prácticas, así el asistente escribe Angular moderno y no el de hace cinco años:

Elección Archivo que se crea
Agents.md AGENTS.md
Claude .claude/CLAUDE.md
GitHub Copilot .github/copilot-instructions.md

Los tres archivos tienen el mismo texto: componentes standalone, signals para el estado, input() y output() en lugar de decoradores, @if y @for en lugar de *ngIf y *ngFor, inject() para los servicios. Son copias: si editas una, las otras no cambian. Si no usas asistentes de IA, elige "Ninguna".

Aparte, el CLI siempre crea .vscode/mcp.json, aunque elijas "Ninguna": es la configuración del servidor MCP de Angular, que le da a los asistentes compatibles herramientas del CLI.

Lo que no pregunta, pero decide por ti. En Angular 22 estas opciones ya vienen resueltas y se cambian con una opción del comando:

Qué decide Valor por defecto Cómo cambiarlo
Rutas Activadas --routing=false
Detección de cambios sin zone.js Sí (zoneless) --zoneless=false
Componentes sin módulos Sí (standalone) —
Herramienta de pruebas Vitest --test-runner=karma
Revisión estricta de tipos Activada --strict=false
Prefijo de las etiquetas app --prefix=otro
Plantilla y estilos en el .ts Archivos aparte --inline-template, --inline-style

Las preguntas pueden cambiar entre versiones. Las opciones disponibles en la tuya se ven con ng new --help.

Todo en una sola línea. Con opciones, el comando no pregunta nada y se puede repetir igual en otra máquina:

ng new bases --directory 02-bases --style=css --ssr=false \
  --ai-config=agents --ai-config=claude --ai-config=copilot

La estructura que queda

Una vista rápida; las carpetas se explican una por una en el Módulo A3 y el resto de las piezas en los módulos siguientes.

02-bases/
├── .angular/                  caché del compilador (se regenera, no se sube a git)
├── .claude/CLAUDE.md          instrucciones para Claude
├── .github/copilot-instructions.md   instrucciones para GitHub Copilot
├── .vscode/                   extensión recomendada, depuración, tareas y MCP
├── public/                    archivos que se copian tal cual (favicon.ico)
├── src/
│   ├── app/
│   │   ├── app.ts             componente raíz: la clase
│   │   ├── app.html           su plantilla
│   │   ├── app.css            sus estilos (vacío al principio)
│   │   ├── app.spec.ts        su prueba
│   │   ├── app.config.ts      configuración global de la aplicación
│   │   └── app.routes.ts      las rutas (vacías al principio)
│   ├── index.html             la única página HTML
│   ├── main.ts                el punto de entrada
│   └── styles.css             estilos globales
├── .editorconfig              reglas de formato para el editor
├── .prettierrc                reglas de formato para Prettier
├── .gitignore
├── AGENTS.md                  instrucciones para asistentes de IA
├── angular.json               cómo construir, servir y probar
├── package.json               paquetes y comandos
├── package-lock.json          versiones exactas instaladas
├── tsconfig.json              reglas de TypeScript comunes
├── tsconfig.app.json          reglas para el código de la aplicación
├── tsconfig.spec.json         reglas para las pruebas
└── README.md

Cómo arranca la aplicación:

Navegador pide la página
   └─ index.html            tiene la etiqueta <app-root></app-root> vacía
        └─ main.ts          bootstrapApplication(App, appConfig)
             └─ App         el componente con selector 'app-root'
                            dibuja su plantilla dentro de <app-root>
  • main.ts es muy corto: le dice a Angular "arranca con este componente y esta configuración".
  • app.config.ts guarda los proveedores globales (servicios y funciones que se activan para toda la aplicación). Ahora tiene dos: el manejo de errores del navegador y las rutas.
  • app.routes.ts es una lista de rutas vacía: la aplicación todavía tiene una sola pantalla.
  • app.html viene con una página de bienvenida larga (unas 340 líneas de estilos y dibujos). Su propio comentario dice que se puede borrar completa. Ojo: app.spec.ts busca el texto Hello, bases en esa plantilla. Si la reemplazas, actualiza también la prueba.

Los comandos (a diferencia del proyecto 01, que usa Vite, aquí todo pasa por el CLI de Angular):

Comando Qué hace
npm start (ng serve) Servidor de desarrollo en http://localhost:4200/, con recarga automática
ng build Genera la versión final en dist/bases/
ng test Ejecuta las pruebas con Vitest
ng generate component nombre Crea un componente con sus archivos

Qué trae package.json: los paquetes @angular/* (el núcleo, las rutas, los formularios), rxjs y tslib como dependencias; y como herramientas de desarrollo, el CLI, el compilador, TypeScript, Vitest, jsdom (un navegador simulado para las pruebas) y Prettier. No hay zone.js: la detección de cambios se apoya en los signals. Qué es package.json y para qué sirve el lockfile está en Configuración del proyecto.

.editorconfig: el mismo formato para todos

El problema. Dos personas editan el mismo archivo: una sangra con dos espacios, la otra con tabuladores; una deja espacios al final de las líneas, la otra no. En git eso aparece como cambios que no cambian nada. .editorconfig es un archivo chico que le dice al editor cómo escribir, y funciona igual en casi todos los editores.

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false

[*.md]
max_line_length = off
trim_trailing_whitespace = false

Cómo se lee. Los corchetes dicen a qué archivos se aplican las reglas de abajo: [*] es todos, [*.ts] sólo los TypeScript, [*.md] sólo los Markdown. Si dos bloques dicen cosas distintas, gana el que está más abajo.

Regla Qué hace
root = true Es la regla de más arriba: el editor no busca otros .editorconfig en carpetas superiores
charset = utf-8 Guarda los archivos en UTF-8, para que las tildes y la ñ no se rompan
indent_style = space Sangra con espacios, no con tabuladores
indent_size = 2 Cada nivel de sangría son dos espacios
insert_final_newline = true Termina cada archivo con una línea en blanco
trim_trailing_whitespace = true Borra los espacios sobrantes al final de cada línea
quote_type = single (en .ts) Prefiere comillas simples
ij_typescript_use_double_quotes = false Lo mismo, pero para los editores de JetBrains (WebStorm, IntelliJ): el prefijo ij_ es de ellos
max_line_length = off (en .md) En Markdown no hay largo máximo de línea
trim_trailing_whitespace = false (en .md) En Markdown no borra espacios al final: dos espacios seguidos al final de una línea significan "salto de línea"

La última regla es la razón por la que .md tiene su propio bloque: lo que en código es basura, en Markdown puede tener significado.

Quién la lee. El editor, mientras escribes. Algunos la entienden solos (los de JetBrains); otros necesitan una extensión: en VS Code es EditorConfig for VS Code (editorconfig.editorconfig). Sin ella, el archivo está pero nadie lo aplica. Ojo: .vscode/extensions.json de este proyecto sólo recomienda la extensión de Angular, no esta.

¿Y .prettierrc? Es otra herramienta con una idea parecida. .editorconfig guía al editor mientras escribes; Prettier reescribe el archivo con un formato fijo cuando lo ejecutas (o al guardar, si el editor lo tiene configurado). Este proyecto trae las dos y no se contradicen: las dos piden comillas simples. .prettierrc agrega dos cosas: el largo de línea (printWidth: 100) y que los .html se lean con el formato de Angular (parser: "angular"), para que entienda @if y @for.

Qué ganas. Menos ruido en los commits y menos discusiones de estilo. Si cambias una regla, hazlo en el archivo y avisa al equipo: el formato de todo el proyecto cambia con ella.

angular.json: el panel de control del CLI

angular.json le dice al CLI cómo construir, servir y probar el proyecto. Cuando escribes ng build, el CLI abre este archivo y busca la receta.

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "cli": { "packageManager": "npm" },
  "newProjectRoot": "projects",
  "projects": {
    "bases": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": { "builder": "@angular/build:application", /* ... */ },
        "serve": { "builder": "@angular/build:dev-server",  /* ... */ },
        "test":  { "builder": "@angular/build:unit-test" }
      }
    }
  }
}

Arriba del todo:

  • $schema: le da al editor la lista de opciones válidas, para autocompletar y avisar de errores de escritura.
  • cli.packageManager: el gestor que usa el CLI (npm).
  • newProjectRoot: dónde se crearían proyectos adicionales dentro de este mismo espacio de trabajo (projects/). Un angular.json puede describir varios proyectos; en este repositorio cada carpeta numerada es su propio espacio con un solo proyecto.

Dentro de projects.bases:

Campo Valor Qué significa
projectType application Es una aplicación (la otra opción es una librería)
root "" El proyecto está en la raíz de la carpeta
sourceRoot src Dónde está el código
prefix app Los selectores empiezan con app-, como app-root

architect: las tareas. Cada tarea tiene un builder, que es el programa que hace el trabajo. angular.json sólo le pasa las opciones.

Tarea Comando Builder Qué hace
build ng build @angular/build:application Compila y empaqueta la aplicación
serve ng serve @angular/build:dev-server Servidor de desarrollo
test ng test @angular/build:unit-test Ejecuta las pruebas (Vitest)

Opciones de build:

Opción Valor Qué hace
browser src/main.ts Por dónde empieza: el punto de entrada
tsConfig tsconfig.app.json Qué reglas de TypeScript usar
assets carpeta public Copia todo lo que hay en public/ a la salida, sin tocarlo
styles src/styles.css Los estilos globales que se cargan en toda la aplicación

Configuraciones: producción y desarrollo. Las opciones de arriba sirven para las dos; cada configuración agrega o cambia algunas.

Configuración Se usa con Qué hace
production ng build (por defecto) Código reducido y optimizado, nombres de archivo con huella, y límites de tamaño
development ng serve (por defecto) Sin optimizar, con mapas del código para depurar en el navegador

Para construir sin optimizar: ng build --configuration development.

  • outputHashing: all: agrega una huella al nombre de cada archivo (main-GW7WULDQ.js). Si el contenido cambia, la huella cambia, y el navegador descarga la versión nueva en lugar de usar una vieja guardada.
  • budgets (presupuestos de tamaño): ponen un tope al peso de la aplicación. Con el proyecto recién creado, el código inicial pesa unos 216 kB (59 kB al transferirse). Los límites son:
Qué mide Avisa desde Falla desde
Código inicial (initial) 500 kB 1 MB
Estilos de un componente 4 kB 8 kB

Pasar el primer límite muestra una advertencia. Pasar el segundo hace fallar ng build. Sirve para que la aplicación no engorde sin que nadie se dé cuenta.

Casi nunca editas angular.json a mano al principio: comandos como ng generate y ng add lo modifican por ti. Lo que sí vas a tocar con el tiempo: agregar estilos globales (styles), carpetas de archivos (assets) y ajustar los presupuestos.

Comparación con el proyecto 01. 01-typescript-intro no tiene angular.json: Vite funciona con sus valores por defecto y los comandos vienen de scripts en package.json. En Angular, angular.json cumple ese papel y los comandos pasan por ng.

Los tsconfig: tres archivos en lugar de uno

El proyecto 01 tiene un solo tsconfig.json. Aquí hay tres, y se relacionan así:

tsconfig.json              reglas comunes (la base)
   ├── tsconfig.app.json   código de la aplicación  → lo usa ng build y ng serve
   └── tsconfig.spec.json  pruebas                  → lo usa ng test
  • tsconfig.json tiene las reglas que valen para todo. Por sí solo no revisa ningún archivo: dice "files": [] y sólo apunta a los otros dos con references. Por eso un npx tsc en la raíz del proyecto no hace nada.
  • tsconfig.app.json hereda de la base (extends) y agrega: revisar src/**/*.ts, excluir las pruebas (*.spec.ts) y "types": [], que significa "no cargues tipos globales de más".
  • tsconfig.spec.json hereda de la base y agrega: revisar sólo las pruebas y cargar vitest/globals, que hace que describe, it y expect existan sin importarlos.

Separarlos tiene sentido: las pruebas necesitan palabras globales (describe, it) que no deberían existir en el código de la aplicación, y el código final no debe incluir las pruebas.

Para revisar los tipos sin construir: npx tsc -p tsconfig.app.json --noEmit. La forma habitual es ng build, que además revisa las plantillas HTML (más abajo).

Las reglas de la base, en simple:

Opción Qué hace
noImplicitOverride Al sobrescribir un método del padre hay que escribir override (ver herencia en el Módulo 8)
noPropertyAccessFromIndexSignature Si un objeto es un diccionario (Record<string, number>), se lee con d['x'] y no con d.x
noImplicitReturns Una función no puede devolver un valor en unos caminos y nada en otros
noFallthroughCasesInSwitch Igual que en el proyecto 01
skipLibCheck Igual que en el proyecto 01
isolatedModules Cada archivo debe poder compilarse solo, porque la herramienta los compila uno por uno
experimentalDecorators Angular usa los decoradores clásicos (Módulo 10); aquí ya viene activado
importHelpers Las funciones auxiliares que TypeScript necesita se toman de tslib en lugar de copiarse en cada archivo
target: ES2022, module: preserve Deja los import y export como están: el empaquetador se encarga de unirlos

angularCompilerOptions: sólo en Angular. Son reglas para el compilador de Angular, que entiende las plantillas HTML:

  • strictInjectionParameters: error si Angular no sabe qué entregar en una inyección.
  • strictInputAccessModifiers: error si desde una plantilla le pasas un dato a un input marcado como privado, protegido o de sólo lectura.
  • enableI18nLegacyMessageIdFormat: false: para las traducciones (i18n) usa el formato nuevo de identificadores. No se toca.

Además hay una regla que no está escrita pero está activa: strictTemplates. Con ella, Angular revisa los tipos dentro de las plantillas: si escribes {{ user().nme }} en vez de name, ng build lo marca como error. TypeScript solo no puede hacerlo: no sabe leer HTML.

Qué cambia respecto al proyecto 01:

Tema 01-typescript-intro (Vite) 02-bases (Angular CLI)
Archivos Uno Tres: base, aplicación y pruebas
Quién compila Vite; tsc sólo revisa (noEmit) El CLI de Angular, que además entiende las plantillas
Cómo se revisan los tipos npx tsc ng build
target es2023 ES2022
module esnext con moduleResolution: bundler preserve
lib ES2023 y DOM, escrito a mano No se escribe: usa los valores por defecto
types vite/client [] en la aplicación, vitest/globals en las pruebas
Importar tipos verbatimModuleSyntax: import type es obligatorio No: import { Hero } from './hero' funciona
Reglas extra noUnusedLocals, noUnusedParameters noImplicitOverride, noPropertyAccessFromIndexSignature, noImplicitReturns
Decoradores experimentalDecorators agregado a mano para el Módulo 10 Ya viene activado
Opciones de Angular No hay angularCompilerOptions y strictTemplates

Qué significa en la práctica para los ejemplos: en 02-bases no hace falta import type, una variable sin usar no rompe el build, y hay que escribir override al sobrescribir. Ninguno de los dos proyectos declara strict: TypeScript 6.0 lo activa por defecto.

Errores comunes

  • Ejecutar ng new dentro de una carpeta que ya está en un repositorio y esperar que cree otro: no lo hace.
  • Cambiar el nombre de la carpeta y creer que cambió el del proyecto: el nombre que usan los comandos está en angular.json (bases), no en la carpeta.
  • Activar SSR "por si acaso": agrega un servidor y más archivos que mantener. Si hace falta después, se agrega con ng add @angular/ssr.
  • Editar sólo uno de los tres archivos de instrucciones para IA y creer que los demás se actualizaron.
  • Ejecutar npx tsc en la raíz y creer que se revisó todo: no revisa nada. Usa ng build.
  • Borrar app.html y olvidar que app.spec.ts busca Hello, bases.
  • Ignorar una advertencia de presupuesto hasta que se vuelve un error y rompe el build.

Resumen

ng new bases pregunta, crea los archivos, instala los paquetes y, si no hay repositorio, lo inicia. Aquí elegimos CSS, SPA y archivos de instrucciones para Claude, GitHub Copilot y AGENTS.md; lo demás (rutas, sin zone.js, Vitest, revisión estricta) viene resuelto. El proyecto arranca desde index.html y main.ts, que levanta el componente App. angular.json define las tareas (build, serve, test), qué herramienta ejecuta cada una y sus configuraciones de producción y desarrollo, con límites de tamaño. Los tres tsconfig separan las reglas comunes, las de la aplicación y las de las pruebas, y se suman las reglas propias de Angular para revisar plantillas.

Preguntas de entrevista (senior)

1. ¿Qué hace ng new y qué decisiones tomas al crear un proyecto?

Respuesta:

Genera el espacio de trabajo y la aplicación inicial, instala los paquetes y, si la carpeta no está en un repositorio, inicia git con un primer commit. Las decisiones que pesan después son tres. SSR o SPA: SPA es más simple y suficiente detrás de un login; SSR conviene para páginas públicas que deben aparecer en buscadores, y se puede agregar después con ng add @angular/ssr. Estilos: CSS plano o Sass; Tailwind si el equipo ya trabaja así. Pruebas: Vitest es el valor por defecto actual; Karma sigue disponible. Para que la creación se pueda repetir en otra máquina, o en un script, se pasan todas las respuestas como opciones, y con --dry-run se revisa antes de escribir nada.

2. ¿Qué es un builder y cómo se relaciona angular.json con ng build?

Respuesta:

angular.json no compila nada: describe tareas. Cada tarea (build, serve, test) apunta a un builder, que es el programa que hace el trabajo (@angular/build:application para construir), y le pasa opciones y configuraciones. Al escribir ng build, el CLI busca la tarea build del proyecto, mezcla las opciones base con las de la configuración elegida (production por defecto) y llama al builder. Eso permite cambiar de herramienta sin cambiar los comandos del equipo, y agregar tareas propias. También por eso ng serve y ng build pueden usar la misma base con configuraciones distintas.

3. ¿Para qué sirven los budgets y qué pasa cuando se superan?

Respuesta:

Son límites de tamaño que el build revisa. Cada uno tiene dos umbrales: al superar el de advertencia, ng build avisa; al superar el de error, el build falla. Por defecto, el código inicial avisa a los 500 kB y falla a 1 MB, y los estilos de un componente, a los 4 kB y 8 kB. Su valor está en la integración continua: una dependencia que sube el peso a escondidas rompe el build en lugar de llegar a producción. Conviene ajustarlos a la realidad de la aplicación (un límite que siempre se ignora no sirve), y recordar que el presupuesto initial sólo mide el código que se descarga al abrir la página: lo que se carga bajo demanda no cuenta ahí.

4. ¿Por qué hay tres archivos tsconfig y qué pasa si ejecutas tsc a secas?

Respuesta:

Porque el código de la aplicación y el de las pruebas necesitan reglas distintas. La base tiene lo común; tsconfig.app.json revisa src/ sin las pruebas y no carga tipos globales extra; tsconfig.spec.json revisa las pruebas y agrega los globales de Vitest. Así describe o it no se pueden usar por accidente en la aplicación, y el build no incluye pruebas. La base declara "files": [] con referencias, de modo que tsc en la raíz no revisa nada y termina sin quejarse, lo que puede dar una falsa tranquilidad. Se usa ng build, o tsc -p tsconfig.app.json --noEmit.

5. ¿Qué revisa el compilador de Angular que TypeScript solo no revisa?

Respuesta:

Las plantillas. TypeScript sólo entiende archivos .ts; el HTML de un componente es para él un texto. El compilador de Angular lee la plantilla y, con strictTemplates (activo por defecto), revisa que las propiedades existan, que los tipos de los input() coincidan con lo que se les pasa y que los eventos reciban lo esperado. Un {{ user().nme }} falla al construir, no cuando un usuario abre la pantalla. Además compila las plantillas por adelantado, antes de entregar la aplicación, en lugar de hacerlo en el navegador. Por eso en un proyecto Angular el chequeo de tipos de verdad es ng build, no tsc.


Módulo A3: Estructura de carpetas por defecto

Proyecto: 02-bases/.

El problema que resuelve

Abres un proyecto Angular recién creado y ves veinte carpetas y archivos. ¿Cuáles escribes tú? ¿Cuáles genera la herramienta y no se tocan? ¿Cuáles se suben a git? Este módulo ordena la carpeta en tres grupos y explica qué hay en cada una. Los archivos de la raíz (angular.json, package.json, tsconfig*.json, .editorconfig) están en el Módulo A2.

Los tres grupos

Grupo Carpetas Quién las crea A git
Lo que escribes tú src/, public/ Tú (el CLI deja un punto de partida) Sí
Lo que genera una herramienta .angular/, dist/, node_modules/ El CLI y npm, al ejecutar comandos No
La configuración del entorno .vscode/, .github/, .claude/ El CLI, una sola vez Sí (ver nota)

En VS Code, las carpetas que git ignora se ven atenuadas en el explorador. En la captura del curso, dist y node_modules se ven más grises: es esa razón.

Un detalle: los nombres de archivo del curso

En el curso, el componente raíz se ve como app.component.ts, app.component.html, app.component.css y app.component.spec.ts. En 02-bases son app.ts, app.html, app.css y app.spec.ts.

Es la misma estructura con otro nombre. Las versiones recientes de Angular dejaron de poner .component en el nombre del archivo; las anteriores lo ponían siempre. Nada cambia en cómo funciona: no hace falta renombrar nada. Si quieres los nombres antiguos en un proyecto nuevo, se pide con ng new --file-name-style-guide=2016.

Las carpetas generadas

.angular/: la memoria del compilador.

Guarda resultados de construcciones anteriores para que la siguiente sea más rápida. Lo que hay adentro se ordena por versión del CLI (cache/22.2.2/bases/) y pesa unos 5 MB.

  • No se edita nunca.
  • Se puede borrar sin miedo: se vuelve a crear en la próxima construcción.
  • Git la ignora (/.angular/cache en el .gitignore).

node_modules/: los paquetes instalados.

Es donde npm install deja todo lo que pide package.json, incluidas las herramientas de Angular. Es enorme, no se edita y no se sube a git. Se recrea con npm install (ver Configuración del proyecto).

dist/: lo que publicas.

No existe hasta que ejecutas ng build. Adentro queda la aplicación terminada, lista para subir a un servidor:

dist/bases/
├── 3rdpartylicenses.txt        licencias de los paquetes que se incluyeron
└── browser/
    ├── index.html              la página, ya con los <script> y <link> puestos
    ├── main-XXXXXXXX.js        tu código (el nombre lleva una huella)
    ├── chunk-XXXXXXXX.js       código compartido que Angular separó
    ├── styles-XXXXXXXX.css     los estilos globales
    └── favicon.ico             lo que venía de public/

Fíjate en lo que no hay: ningún .html de componente. Las plantillas se compilan y pasan a formar parte del JavaScript. Tampoco hay .ts: todo se tradujo. Se puede borrar y volver a generar cuando quieras. Git la ignora (/dist).

La configuración del entorno

.vscode/: ajustes de VS Code para este proyecto.

Archivo Para qué sirve
extensions.json Recomienda instalar angular.ng-template, el servicio de lenguaje de Angular (autocompleta y marca errores dentro de las plantillas)
launch.json Dos configuraciones para depurar en Chrome: ng serve y ng test
tasks.json Las tareas npm: start y npm: test, que el depurador ejecuta antes de abrir Chrome
mcp.json La configuración del servidor MCP de Angular, para asistentes de IA

La configuración ng test del depurador apunta a una página de Karma (puerto 9876). Este proyecto usa Vitest, así que ese lanzador no aplica tal cual.

Nota para este repositorio: el .gitignore de la raíz ignora .vscode/, así que estos archivos no se suben a git aunque el .gitignore del propio proyecto los dejaría pasar.

.github/ y .claude/ guardan las instrucciones para GitHub Copilot y para Claude (Módulo A2).

La carpeta public/

Todo lo que pones aquí se copia tal cual a la raíz del resultado, sin compilar ni cambiar el nombre. Por eso favicon.ico queda junto a index.html en dist/bases/browser/, y index.html lo pide con href="favicon.ico", sin public/ en la ruta.

Regla práctica: si el archivo no pasa por el compilador (imágenes, fuentes, robots.txt, íconos), va en public/. Si es código o estilos que se importan, va en src/.

La carpeta src/

src/
├── app/
│   ├── app.ts            el componente raíz (la clase)
│   ├── app.html          su plantilla
│   ├── app.css           sus estilos
│   ├── app.spec.ts       su prueba
│   ├── app.config.ts     configuración global
│   └── app.routes.ts     las rutas
├── index.html            la página
├── main.ts               el punto de entrada
└── styles.css            los estilos globales

Dentro de src/app/ va todo tu código: componentes, servicios, pipes. Los módulos siguientes lo van llenando.

index.html

Es la única página HTML de la aplicación:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bases</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>
  • <app-root></app-root> está vacía: es el lugar donde Angular dibuja la aplicación. Su nombre viene del selector: 'app-root' del componente App.
  • <base href="/"> es la dirección de partida para todos los enlaces relativos. Las rutas la necesitan para funcionar.
  • No hay ningún <script> ni <link> de estilos: los agrega ng build al construir, con los nombres con huella.
  • lang="en" indica que la página está en inglés. Si la aplicación es en español, cámbialo a es: lo usan los lectores de pantalla y los buscadores.

main.ts: el punto de entrada

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));

Es el primer archivo que se ejecuta. Bootstrap quiere decir "arrancar", y bootstrapApplication hace tres cosas:

  1. Toma el componente App como raíz.
  2. Lo dibuja dentro de la etiqueta <app-root> de index.html.
  3. Aplica appConfig, la configuración global (rutas, manejo de errores).

Devuelve una promesa; el .catch imprime en la consola si el arranque falla.

Todo lo demás se ejecuta porque main.ts lo importa, directa o indirectamente. Un archivo que nadie importa no se ejecuta. Es el mismo mecanismo de import del Módulo 7. El CLI sabe que éste es el punto de entrada por angular.json ("browser": "src/main.ts").

En el proyecto 01 pasa algo parecido: index.html carga src/main.ts. La diferencia es que allí lo escribe uno a mano y aquí lo agrega el CLI al construir.

Legacy: antes de los componentes standalone, main.ts arrancaba un módulo: platformBrowserDynamic().bootstrapModule(AppModule). Hoy se arranca un componente con bootstrapApplication.

Dentro de src/app/

Archivo Qué contiene
app.ts La clase App, con @Component: su selector, qué importa y dónde están plantilla y estilos
app.html La plantilla. Viene con una página de bienvenida que se puede borrar
app.css Los estilos de este componente. Empieza vacío
app.spec.ts Su prueba
app.config.ts Los proveedores globales: rutas y manejo de errores
app.routes.ts La lista de rutas, vacía al principio

app.config.ts y app.routes.ts: cómo se conectan las rutas

Una SPA muestra una sola página HTML y cambia lo que hay adentro según la dirección (/, /about). Tres archivos se reparten ese trabajo:

// app.routes.ts: la lista de rutas (vacía al crear el proyecto)
export const routes: Routes = []

// app.config.ts: activa el enrutador con esa lista
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
  ],
}
<!-- app.html: el hueco donde se dibuja la página de la ruta actual -->
<router-outlet />
Pieza Qué hace
app.routes.ts Une una dirección con un componente. Vacía, la aplicación sólo muestra App
app.config.ts Lista de providers: funciones que activan cosas para toda la aplicación. main.ts se la pasa a bootstrapApplication
provideRouter(routes) Activa el enrutador con la lista de app.routes.ts
provideBrowserGlobalErrorListeners() Envía a Angular los errores del navegador que nadie capturó, para que los trate su manejador de errores
RouterOutlet y <router-outlet /> El componente se importa en app.ts y la etiqueta se escribe en app.html: marcan dónde aparece cada página

Con dos páginas, la lista se ve así:

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'about', component: About },
  { path: '**', component: NotFound },
]
  • '' es la dirección raíz (/).
  • 'about' es /about.
  • '**' significa "cualquier otra dirección" y sirve para la página de "no encontrado".

El orden importa. El enrutador recorre la lista de arriba hacia abajo y usa la primera ruta que coincide. Por eso '**' va siempre al final: una ruta escrita después de ella nunca se alcanza.

El recorrido:

Cambia la dirección (/about)
   └─ El enrutador busca, de arriba a abajo, la primera ruta que coincide
        └─ Dibuja ese componente justo después de <router-outlet />
             (sin recargar la página del navegador)

Para ir de una página a otra se usan enlaces con routerLink en lugar de href; así Angular cambia la vista sin recargar:

<a routerLink="/about">Acerca de</a>
<router-outlet />

RouterLink se importa en el componente igual que RouterOutlet: imports: [RouterOutlet, RouterLink]. Angular convierte ese enlace en un <a href="/about"> normal, así que el botón derecho y "abrir en una pestaña nueva" siguen funcionando.

Ojo con app.html: la página de bienvenida que genera ng new termina con <router-outlet /> (en la línea 344). Si la borras entera, deja esa etiqueta, o ninguna ruta tendrá dónde dibujarse.

Los parámetros en la dirección (/heroes/:id), las rutas hijas, los guards y la carga bajo demanda se ven en el módulo de rutas; la sección Rutas del Módulo A1 ya adelanta algunos.

Estilos globales y estilos de componente

Hay dos lugares para escribir CSS, y se comportan distinto.

Tema Globales De componente
Archivo src/styles.css app.css (uno por componente)
Cómo se conecta angular.json, en styles styleUrl: './app.css' o styles en @Component
A quién afectan A toda la aplicación Sólo a la plantilla de ese componente
Para qué se usa Base común: tipografía, colores, variables Cómo se ve cada pieza

Estilos globales. Lo que escribas en styles.css vale en todas las pantallas y en todos los componentes. Es el lugar para:

  • Variables CSS compartidas: :root { --color-principal: #6b21a8 }. Cualquier componente las puede usar con var(--color-principal).
  • Tipografía y márgenes generales del body.
  • Estilos de una librería externa: se agrega su archivo a la lista styles de angular.json.

Estilos de componente. Lo que escribas en app.css sólo afecta a la plantilla de App. Si en otro componente tienes un <p>, no se ve afectado. Esto es lo que hace seguro escribir p { color: red } sin miedo a cambiar medio proyecto.

Cómo lo logra Angular. Mira qué pasa con un componente que escribe p { color: red }. Angular marca los elementos de ese componente con un atributo con un nombre único, y cambia el CSS para exigirlo:

Tú escribes:             p { color: red }
Angular deja en la página: p[_ngcontent-xxx] { color: red }

<p _ngcontent-xxx>            ← de este componente: recibe el estilo
<p>                           ← de otro componente, sin la marca: no lo recibe

El atributo (_ngcontent-xxx) lo recibe cada elemento de la plantilla del componente, y su final es distinto en cada componente. La regla queda atada a él.

:host: el elemento del propio componente. Las reglas del componente sólo alcanzan a los elementos de su plantilla; la etiqueta del propio componente (<app-root>) queda afuera. Para darle estilo se usa :host:

:host {
  display: block;
}

Angular lo traduce a [_nghost-xxx] { display: block }, con otro atributo que sólo lleva ese elemento.

¿Y si chocan? Si una regla global y una de componente apuntan al mismo elemento, gana la del componente: su selector es más específico, porque lleva el atributo extra.

Los estilos de un componente no llegan adentro de sus hijos. Los elementos de un componente hijo llevan la marca del hijo, no la del padre. Existe ::ng-deep para saltarse ese aislamiento, pero lo rompe a propósito: úsalo sólo como último recurso.

Tres modos de aislamiento. Se elige con encapsulation en @Component:

Modo (ViewEncapsulation) Qué hace
Emulated (por defecto) Lo que acabas de ver: atributos únicos y CSS reescrito
None Sin aislamiento: p { color: red } queda tal cual y afecta a todos los <p> de la aplicación
ShadowDom Usa el aislamiento del propio navegador (Shadow DOM)

Con None, Angular deja p { color: red } sin marcas y los <p> no reciben ningún atributo. Por eso None es como escribir en styles.css pero desde un componente: funciona, pero sus estilos se escapan.

Conexión con angular.json. El límite anyComponentStyle (4 kB de aviso y 8 kB de error) del Módulo A2 mide justamente el CSS de un componente.

Cuándo usar cada uno.

  • Lo que debe verse igual en toda la aplicación (colores, tipografía, variables): global.
  • Lo que sólo importa a una pieza (el borde de una tarjeta, el espaciado de una lista): de componente. Es el valor por defecto: empieza por aquí.
  • Si te encuentras copiando el mismo estilo en cinco componentes, sube la parte común a una variable en styles.css.

Plantilla en el componente o en un archivo aparte

Un componente puede llevar su HTML y su CSS escritos adentro o en archivos aparte. Las dos formas hacen lo mismo:

// Adentro (inline)
@Component({
  selector: 'app-badge',
  template: `<span class="badge">{{ label() }}</span>`,
  styles: `.badge { padding: 2px 8px; }`,
})
export class Badge {
  readonly label = input.required<string>()
}

// En archivos aparte
@Component({
  selector: 'app-hero-list',
  templateUrl: './hero-list.html',
  styleUrl: './hero-list.css',
})
export class HeroList {}
  • template y styles llevan el contenido. templateUrl y styleUrl llevan la ruta a un archivo, relativa al .ts del componente (./).
  • Se pueden mezclar: plantilla adentro y estilos en un archivo, o al revés.
  • ng new y ng generate component crean archivos aparte por defecto. Para pedir lo contrario: --inline-template (-t) y --inline-style (-s).

No hay diferencia de rendimiento. Al construir, Angular compila la plantilla y los estilos y los pone dentro del JavaScript, vengan de donde vengan. Por eso dist/ no tiene ningún .html de componente. Es una decisión de comodidad, no de velocidad.

Cuándo conviene dentro del componente:

Situación Por qué
Plantilla corta, unas pocas líneas La ves junto a la clase, sin cambiar de archivo
Componente pequeño y presentacional (botón, etiqueta, tarjeta) Todo el componente cabe en una pantalla
Estilos mínimos o ninguno Un archivo .css casi vacío no aporta nada
Ejemplos, pruebas y documentación Se copian y se leen enteros (esta guía lo hace así)

Cuándo conviene un archivo aparte:

Situación Por qué
Plantilla larga o con muchos @if y @for anidados Dentro de un texto del .ts cuesta leerla
Mucho CSS La clase queda enterrada entre estilos
Alguien trabaja el HTML y el CSS por separado de la lógica Cada persona toca su archivo
Quieres que los cambios de vista se vean limpios en git Un cambio de HTML no aparece mezclado con la clase
Tu equipo ya lo definió Lo importante es que sea igual en todo el proyecto

Regla rápida. Si al abrir el .ts tienes que bajar la pantalla para ver dónde termina la plantilla, sácala a un archivo. Si cabe cómoda junto a la clase, déjala adentro.

Las instrucciones para asistentes de IA que genera Angular (AGENTS.md) dicen lo mismo: preferir plantillas inline para componentes pequeños y, al usar archivos externos, rutas relativas al .ts.

Un ejemplo: el contador

02-bases/ ya tiene un componente sencillo para ver las piezas juntas: un número y tres botones. Aquí no se resuelve ningún problema; sólo se muestra cómo encajan las partes que acabas de ver. Vive en src/app/pages/counter/, con tres archivos que comparten el nombre base:

src/app/pages/counter/
├── counter-page.component.ts     la clase
├── counter-page.component.html   la plantilla
└── counter-page.component.css    sus estilos (vacío: los pone Bootstrap)
@Component({
  selector: 'app-counter',
  templateUrl: './counter-page.component.html',
  styleUrls: ['./counter-page.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterPageComponent {
  public counter: number = 1
  public counterSignal: WritableSignal<number> = signal(1)
  public title: string = 'Counter Component'

  public incrementBy(value: number = 1): void {
    this.counter += value
    this.counterSignal.update((current) => current + value)
  }

  public decrementBy(value: number = 1): void {
    this.counter -= value
    this.counterSignal.update((current) => current - value)
  }

  reset(): void {
    this.counter = 1
    this.counterSignal.set(1)
  }
}

El contador guarda el mismo número dos veces a propósito: counter es una propiedad normal y counterSignal es un signal. Así se ven lado a lado en la página (más abajo se explica la diferencia). styleUrls recibe una lista de archivos; con uno solo basta styleUrl, y las dos formas funcionan.

<div class="card page-card mx-auto my-5 text-center">
  <div class="card-body">
    <h1 class="card-title h4 mb-3">{{ title }}</h1>
    <p class="fs-3 fw-bold mb-2">Counter: {{ counter }}</p>
    <p class="fs-3 fw-bold mb-3">Counter Signal: {{ counterSignal() }}</p>
    <div class="d-flex justify-content-center gap-2">
      <button class="btn btn-primary" (click)="incrementBy()">+1</button>
      <button class="btn btn-outline-secondary" (click)="reset()">Reset</button>
      <button class="btn btn-primary" (click)="decrementBy()">-1</button>
    </div>
  </div>
</div>

Las clases (card, btn, fs-3, gap-2) son de Bootstrap; se explican en el último párrafo de esta sección.

Pieza Dónde Qué hace
counter, title Clase Propiedades: los datos que guarda el componente
counterSignal Clase Un signal: un dato que avisa cuando cambia (se explica abajo)
incrementBy(), reset() Clase Métodos: lo que el componente sabe hacer
value: number = 1 Clase Valor por defecto: si llamas incrementBy() sin nada, suma 1
{{ counter }} Plantilla Interpolación: escribe el valor de la propiedad en la página
{{ counterSignal() }} Plantilla Lo mismo con un signal: se lee con paréntesis
(click)="incrementBy()" Plantilla Evento: cuando se hace clic en el botón, Angular llama al método

El recorrido de un clic:

Clic en "+1"
   └─ Angular llama a incrementBy()
        └─ counter y counterSignal pasan de 1 a 2
             └─ Angular vuelve a dibujar la página con el 2

Cómo llega a la pantalla. El componente no se escribe en ningún HTML: la ruta de la raíz lo muestra. app.html tiene la barra de navegación (<app-navbar>, se explica en el Módulo A4) y, debajo, un <section> con el <router-outlet />.

// app.routes.ts
export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./pages/counter/counter-page.component').then((m) => m.CounterPageComponent),
  },
  {
    path: 'hero',
    loadComponent: () =>
      import('./pages/hero/hero-page.component').then((m) => m.HeroPageComponent),
  },
  {
    path: 'dragonball',
    loadComponent: () =>
      import('./pages/dragonball/dragonball-page.component').then(
        (m) => m.DragonballPageComponent,
      ),
  },
  {
    path: 'dragonball-super',
    loadComponent: () =>
      import('./pages/dragonball-super/dragonball-super-page.component').then(
        (m) => m.DragonballSuperPageComponent,
      ),
  },
  { path: '**', redirectTo: '' },
]

Se usa loadComponent en lugar de component: el componente se descarga sólo cuando alguien visita la ruta (carga diferida, lazy loading). Al ejecutar ng build, cada página sale como un archivo aparte (el contador, de poco más de 1 kB) y no dentro del archivo principal. La ruta / muestra el contador, /hero el héroe (el ejemplo de abajo) y /dragonball y /dragonball-super las listas del Módulo A4. La última ruta, '**', redirige a / lo que no existe.

Estilos: Bootstrap y un poco de CSS global. En lugar de escribir CSS en cada componente, el proyecto usa Bootstrap, una librería que ya trae estilos listos y se usa poniendo clases en el HTML. Se carga con una etiqueta <link> en index.html, y por eso los CSS de los componentes quedaron vacíos.

Clase Qué hace
card, card-body Una tarjeta con borde, y su interior con espacio
mx-auto my-5 Centrada (margen automático a los lados) y separada de arriba y abajo
text-center Texto centrado
fs-3, fw-bold Tamaño de letra grande y negrita
btn btn-primary Botón azul. btn-outline-secondary es uno con sólo borde
d-flex gap-2 Pone los hijos en fila, con espacio entre ellos

Sólo hace falta una regla propia, y va en styles.css porque la usan varias páginas: .page-card { max-width: 360px; }, ya que una tarjeta de Bootstrap ocupa todo el ancho. La tipografía también está ahí. Lo que sirve a un solo componente va en su CSS; lo que sirve a varios, en el global.

Otro ejemplo: el héroe

src/app/pages/hero/ repite las mismas piezas con dos signals (name y age), dos valores derivados de ellos y cuatro botones. Aquí se ve un uso un poco distinto de cada parte:

@Component({
  imports: [UpperCasePipe],
  // ...
})
export class HeroPageComponent {
  private readonly initialName: string = 'Geralt of Rivia'
  private readonly initialAge: number = 120
  private readonly otherName: string = 'Ciri'
  private readonly otherAge: number = 21

  public name: WritableSignal<string> = signal('Geralt of Rivia')
  public age: WritableSignal<number> = signal(120)

  heroDescription: Signal<string> = computed(
    () => `${this.name()} is ${this.age()} years old.`,
  )
  capitalizedHeroName: Signal<string> = computed(() => this.name().toUpperCase())

  getHeroDescription(): string {
    return `${this.name()} is ${this.age()} years old.`
  }

  changeHero(): void {
    this.name.set(this.otherName)
    this.age.set(this.otherAge)
  }

  changeAge(): void {
    this.age.set(60)
  }

  resetHero(): void {
    this.name.set(this.initialName)
    this.age.set(this.initialAge)
  }

  increaseAge(): void {
    this.age.update((age) => age + 1)
  }
}
<dl class="row mb-4">
  <dt class="col-5">Method:</dt>
  <dd class="col-7">{{ getHeroDescription() }}</dd>

  <dt class="col-5">Computed:</dt>
  <dd class="col-7">{{ heroDescription() }}</dd>

  <dt class="col-5">Capitalized:</dt>
  <dd class="col-7">{{ capitalizedHeroName() }}</dd>

  <dt class="col-5">Capitalized with Pipe:</dt>
  <dd class="col-7">{{ name() | uppercase }}</dd>
</dl>
  • Un método también puede leer signals. getHeroDescription() lee name() y age(). Si la plantilla llama al método, la pantalla se actualiza cuando cualquiera de los dos cambia: Angular se da cuenta de qué signals se leyeron mientras se dibujaba la página.
  • set o update. changeHero() y resetHero() usan set: ya saben el valor nuevo. increaseAge() usa update: la edad nueva depende de la actual.
  • Un valor que sale de otros: computed. heroDescription y capitalizedHeroName no se modifican a mano: se calculan a partir de name y age. Por eso su tipo es Signal (sólo lectura) y no WritableSignal, y no tienen set ni update. Se actualizan solos cuando cambia alguno de los signals que leen, y se leen igual que cualquier signal, con paréntesis: heroDescription(). No hace falta crear un signal nuevo y acordarse de actualizarlo.
  • computed o método. Los dos dan el mismo texto, pero no trabajan igual: el método se ejecuta cada vez que se vuelve a dibujar la plantilla, aunque haya cambiado otra cosa que no tiene nada que ver. El computed guarda el resultado y sólo lo recalcula cuando cambia name o age. Para un texto corto da igual; con un cálculo pesado, conviene el computed.
  • computed o pipe. Para mayúsculas hay tres caminos que se ven igual: name().toUpperCase() escrito en la plantilla, el pipe uppercase (imports: [UpperCasePipe]) y un computed. El pipe es lo más corto para mostrar un dato con otro formato; el computed conviene cuando el valor derivado se usa en varios sitios o también lo necesita la clase.
  • Valores fijos con private readonly. Los nombres y edades que usan varios métodos se guardan una sola vez. Son privados porque la plantilla no los necesita, y readonly porque nunca cambian.
  • <dl>, <dt>, <dd>. Es la lista de definiciones de HTML: dt es el término y dd su valor. Con row y col-5/col-7 de Bootstrap quedan en dos columnas.

Signals: un dato que avisa cuando cambia

En el contador hay dos números que parecen iguales y no lo son:

  • counter es una propiedad normal. Angular no sabe cuándo cambia: sólo sabe que "pasó algo" (un clic) y entonces vuelve a mirar la plantilla.
  • counterSignal es un signal: una caja que guarda un valor y avisa a Angular cuando ese valor cambia.

El problema que resuelven. Con propiedades normales, Angular tiene que adivinar cuándo redibujar. Funciona con un clic, porque el evento le avisa, pero falla cuando el dato cambia por otro camino. Por ejemplo, después de una espera:

async cargarDespues(): Promise<void> {
  await Promise.resolve()   // algo asíncrono (una petición, un temporizador)
  this.counter++            // la propiedad cambia, pero la pantalla sigue igual
}

La propiedad ya vale 2 y la página sigue mostrando 1. El valor aparece de golpe más tarde, cuando otra cosa provoca un redibujado, y por eso es un error difícil de rastrear. Con un signal no pasa: cada vez que cambia, Angular se entera y actualiza sólo los sitios de la plantilla que lo leen.

Cómo se usa:

public counterSignal: WritableSignal<number> = signal(1)   // crear, con valor inicial

this.counterSignal.set(5)                                  // poner un valor nuevo
this.counterSignal.update((current) => current + 1)        // calcularlo desde el anterior
<p>{{ counterSignal() }}</p>   <!-- leer: con paréntesis -->
Operación Código Qué hace
Leer counterSignal() Devuelve el valor actual. Un signal es una función
Escribir counterSignal.set(5) Pone directamente un valor nuevo
Calcular counterSignal.update(c => c + 1) Recibe el valor actual y devuelve el nuevo
  • set o update. Usa set cuando ya sabes el valor nuevo (reset vuelve a 1). Usa update cuando el valor nuevo depende del anterior (sumar, restar, agregar a una lista).
  • Los paréntesis no son opcionales. Si escribes {{ counterSignal }} sin ellos, la página muestra el texto [Signal (counterSignal): 1] en lugar del número, y Angular lo avisa al compilar.
  • signal y WritableSignal. signal(1) crea un WritableSignal<number>: un signal que se puede leer y cambiar. El tipo Signal<number> es el que sólo se puede leer. Un componente o servicio suele guardar el WritableSignal como privado y mostrar hacia afuera una versión de sólo lectura con .asReadonly(); es el patrón de Servicios.

Cuándo usar cada uno: signal, computed, set y update

Una pregunta resuelve casi todo: ¿este valor lo decido yo, o sale de otros?

signal (que se puede cambiar) o computed. Un carrito de compras:

price = signal(10)                                   // lo cambia alguien: el precio
quantity = signal(1)                                 // lo cambia el usuario: la cantidad
total = computed(() => this.price() * this.quantity())   // sale de los dos
<p>Total: {{ total() }}</p>

Si quantity pasa a 3, total pasa solo a 30; con 4, a 40. Nadie lo actualiza a mano.

El valor... Usa Ejemplos
Lo cambia el usuario, el servidor o un evento signal cantidad, texto de un campo, si un menú está abierto
Se puede calcular a partir de otros signals computed total, nombre en mayúsculas, "¿hay elementos?", filtrar

El error típico es guardar en un signal algo que se puede calcular:

total = signal(10)               // mal: hay que acordarse de actualizarlo
quantity.set(3)                  // total sigue en 10 si se olvida

Con computed ese olvido no existe. Además, computed es de sólo lectura: no tiene set ni update, y escribir total.set(5) es un error al compilar. Eso es lo que se quiere: un valor derivado no se pisa, se cambia el origen.

set o update. Un menú que se abre y se cierra, y una lista:

isOpen = signal(false)
items = signal<string[]>(['manzana'])
quantity = signal(1)

// set: ya sé el valor nuevo
close(): void {
  this.isOpen.set(false)
}
reset(): void {
  this.quantity.set(1)
}

// update: el valor nuevo depende del anterior
toggle(): void {
  this.isOpen.update((open) => !open)
}
increase(): void {
  this.quantity.update((q) => q + 1)
}
add(item: string): void {
  this.items.update((list) => [...list, item])
}
Situación Usa Ejemplo
El valor viene de fuera o es fijo set name.set('Ciri'), quantity.set(1)
Volver al valor inicial set reset()
Sumar, restar, contar update quantity.update(q => q + 1)
Cambiar de verdadero a falso y al revés update isOpen.update(open => !open)
Agregar o quitar de una lista update items.update(list => [...list, x])
  • set(this.quantity() + 1) también funciona. Da el mismo resultado que update. Se prefiere update porque dice con claridad "el nuevo valor sale del anterior" y no hay que leer el signal por separado.
  • En una lista, crea una lista nueva. El signal avisa cuando recibe un valor distinto. Si haces this.items().push('pera'), la lista cambia por dentro pero sigue siendo la misma, y la pantalla no se entera: se queda mostrando 1 elemento aunque ya haya 2. Con [...list, item] es una lista nueva y la pantalla se actualiza.

Zoneless: Angular sin zone.js

La pregunta de fondo: ¿cómo sabe Angular cuándo redibujar la página?

Antes: zone.js. Era una librería que "vigilaba" el navegador. Modificaba por debajo funciones como setTimeout, los eventos y las promesas, para enterarse de que algo terminó. Cada vez que eso pasaba, Angular revisaba todos los componentes, por si alguno había cambiado. Funcionaba sin que el programador hiciera nada, pero tenía costos:

  • Es un paquete más que se descarga junto con la aplicación.
  • Revisa componentes que no cambiaron, sólo porque "pasó algo".
  • Es magia: cuesta entender por qué algo se redibuja (o no), y los errores salen con rastros difíciles de leer.

Ahora: zoneless (sin zonas). No hay zone.js. Angular no vigila el navegador: redibuja cuando alguien le avisa. Y le avisan cuatro cosas:

Qué ocurre Ejemplo
Cambia un signal que la plantilla lee counterSignal.update(...)
Se dispara un evento escrito en la plantilla (click)="incrementBy()"
Llega un valor nuevo por el pipe async {{ datos$ \| async }}
Pides el redibujado a mano ChangeDetectorRef.markForCheck()

Eso explica lo que se ve en el contador. counter se actualiza con un clic porque el evento avisó, aunque sea una propiedad normal. En cambio, el this.counter++ de cargarDespues() no avisa a nadie, y la pantalla no se entera. counterSignal funciona siempre, venga el cambio de donde venga: un signal es la forma de avisar.

Cómo está en este proyecto:

  • package.json no tiene zone.js.
  • app.config.ts no tiene nada especial: en Angular 22 zoneless es lo normal. Para volver al modo anterior habría que crear el proyecto con --zoneless=false (ver la tabla de ng new, en el Módulo A2).

Por qué se usa:

  • Menos peso: una librería menos que descargar.
  • Menos trabajo: Angular actualiza sólo lo que cambió, no todo el árbol.
  • Más claro: el redibujado tiene una causa visible (un signal, un evento), no una vigilancia invisible.
  • Mejor encaje: los signals y zoneless se diseñaron juntos.

¿Y OnPush? El contador también declara changeDetection: ChangeDetectionStrategy.OnPush. Es otra decisión, distinta de zoneless:

  • Zoneless decide quién avisa a Angular: ya no hay vigilancia automática.
  • OnPush decide qué componentes se revisan: sólo los que recibieron un aviso (un evento de su plantilla, un signal que su plantilla lee, un valor nuevo en un input o markForCheck()). Los demás se saltan.

Una no depende de la otra: OnPush ya existía cuando había zone.js. Pero se llevan bien: el signal avisa y OnPush revisa sólo lo avisado. Con OnPush sigue pasando lo mismo que se vio arriba: el this.counter++ después de una espera no se refleja en la pantalla.

Regla práctica: si un dato aparece en la plantilla y puede cambiar con el tiempo (no sólo con un clic), guárdalo en un signal.

Errores comunes

  • Subir node_modules/, dist/ o .angular/ a git. Ya están en el .gitignore: no los saques de ahí.
  • Editar algo dentro de dist/ creyendo que cambia la aplicación: se sobrescribe en el próximo ng build.
  • Poner en src/ un archivo que no se compila (una imagen) y no encontrarlo después: va en public/.
  • Escribir estilos globales de una pieza en styles.css: afectan a toda la aplicación. Van en el CSS del componente.
  • Esperar que el CSS de un componente padre cambie lo de adentro de un hijo.
  • Usar ViewEncapsulation.None "para que funcione" y que los estilos se escapen a otros componentes.
  • Poner templateUrl: 'hero-list.html' sin ./: la ruta es relativa al .ts y conviene escribirla con ./.
  • Dejar lang="en" en una aplicación en español.

Resumen

La carpeta del proyecto tiene tres grupos: lo que escribes (src/, public/), lo que generan las herramientas (.angular/, dist/, node_modules/; git los ignora y se pueden borrar) y la configuración del entorno (.vscode/, .github/, .claude/). index.html tiene la etiqueta <app-root> vacía; main.ts arranca el componente App dentro de ella con bootstrapApplication. public/ se copia tal cual a la salida. Los estilos globales (styles.css) afectan a toda la aplicación; los de componente (app.css) sólo a su plantilla, porque Angular marca sus elementos y reescribe el CSS. La plantilla va dentro del componente si es corta y en un archivo si es larga; el rendimiento es el mismo.

Preguntas de entrevista (senior)

1. ¿Qué carpetas de un proyecto Angular se suben a git y cuáles no?

Respuesta:

Se suben las que escribes o configuras: src/, public/, angular.json, package.json, package-lock.json, los tsconfig*.json, .editorconfig y .prettierrc. No se suben las que se regeneran: node_modules/ (se recrea con npm install), dist/ (se recrea con ng build) y .angular/cache (la caché del compilador). El package-lock.json sí se sube a propósito: asegura que todo el equipo y la integración continua instalen las mismas versiones. La regla general: se sube lo que no se puede reconstruir a partir de otra cosa.

2. ¿Cómo logra Angular que los estilos de un componente no afecten a otros?

Respuesta:

Con el modo Emulated, el predeterminado. Angular marca cada elemento de la plantilla del componente con un atributo único (_ngcontent-xxx) y reescribe cada regla del CSS para exigir ese atributo: p { … } queda como p[_ngcontent-xxx] { … }. Así sólo alcanza a los elementos de ese componente. El elemento de la propia etiqueta se marca con otro atributo, que es el que usa :host. No usa nada especial del navegador: es sólo CSS con atributos. Consecuencias: una regla de componente gana a una global del mismo elemento por ser más específica, y los estilos de un padre no llegan al interior de un hijo. ViewEncapsulation.None desactiva todo esto y ShadowDom lo reemplaza por el aislamiento del propio navegador.

3. ¿Qué va en styles.css y qué en el CSS de un componente?

Respuesta:

En styles.css, lo que define la identidad común de la aplicación: variables CSS (colores, espaciados, tipografías), un reinicio básico y los estilos de las librerías externas. En el CSS del componente, lo que sólo importa a esa pieza: así se puede cambiar o borrar el componente sin efectos secundarios en otro lado, y el aislamiento evita choques de nombres de clase. Un buen patrón es declarar las variables en :root dentro de styles.css y usarlas con var(...) desde los componentes: se obtiene aislamiento y consistencia a la vez. Si hay CSS repetido en muchos componentes, suele ser señal de que falta una variable o un componente compartido.

4. ¿Qué pasa desde que el navegador abre index.html hasta que se ve la aplicación?

Respuesta:

El navegador descarga index.html, que tiene la etiqueta <app-root> vacía y, agregado por el build, el <script> de main-XXXX.js y el <link> de los estilos globales. Al ejecutar el script, corre main.ts: llama a bootstrapApplication(App, appConfig), que crea el componente raíz, aplica la configuración global (rutas, manejo de errores) y dibuja su plantilla dentro de <app-root>. Si hay rutas, el router mira la dirección y dibuja el componente que corresponda en el <router-outlet>. En una SPA el HTML llega casi vacío y el contenido lo dibuja el JavaScript; con SSR, el servidor ya manda <app-root> con contenido (Módulo A1).

5. ¿Plantilla dentro del componente o en un archivo aparte? ¿Cambia algo en el resultado?

Respuesta:

No cambia el resultado: al construir, Angular compila la plantilla y los estilos y los incluye en el JavaScript, así que no hay diferencia de rendimiento ni de tamaño por esa decisión. Es de legibilidad y de trabajo en equipo. Dentro del componente conviene para plantillas cortas y componentes pequeños, donde ver la vista junto a la clase ayuda; en un archivo aparte conviene cuando la plantilla o el CSS son largos, cuando varias personas tocan vista y lógica por separado, o cuando se quieren diffs de git limpios. Se puede mezclar (plantilla adentro, estilos afuera) y el generador de componentes lo permite con --inline-template y --inline-style. Lo que sí importa es la coherencia: elegir un criterio, por ejemplo "inline si cabe en pantalla", y aplicarlo en todo el proyecto.


Módulo A4: Temas puntuales

Este módulo recorre temas sueltos que se necesitan para armar una aplicación de varias páginas: moverse entre páginas, repetir y mostrar elementos según una condición, y poner clases según el estado. Se va llenando a medida que avanza el curso; por ahora cubre la navegación, el nuevo control flow, la comunicación entre componentes (input() y output()), los servicios y los efectos con localStorage.

El código sale de 02-bases/: la barra de navegación en src/app/components/shared/navbar/, la página dragonball en src/app/pages/dragonball/ y su versión repartida en componentes, dragonball-super, con los hijos en src/app/components/dragonball/ y el servicio en src/app/services/.

El problema que resuelve

Una aplicación de una sola página (SPA) tiene varias "páginas", pero el navegador nunca recarga: Angular cambia lo que hay dentro de <router-outlet />. Hace falta una forma de ir de una página a otra sin recargar y de marcar en qué página estás. Y dentro de cada página, hace falta repetir una lista y mostrar u ocultar cosas según los datos.

Un enlace normal (<a href="/hero">) hace que el navegador pida la página otra vez y la aplicación arranque de cero. routerLink hace lo mismo para el usuario, pero le dice a Angular que cambie la ruta, sin recargar:

<a [routerLink]="['hero']">Hero</a>
  • ['hero'] es una lista porque la dirección se puede armar por partes: ['heroes', id] da /heroes/5. Para una dirección fija también sirve routerLink="hero".
  • El componente tiene que importar RouterLink, porque los componentes standalone no ven nada que no importen:
@Component({
  imports: [RouterLink, RouterLinkActive],
  selector: 'app-navbar',
  templateUrl: './navbar.component.html',
})
export class NavbarComponent {}
  • No hace falta escribir href. Angular lo pone solo en la etiqueta <a> (por ejemplo href="/hero"), así que el clic derecho y "abrir en una pestaña nueva" siguen funcionando. Si escribes un href a mano, Angular lo sobrescribe.

RouterLinkActive: marcar la página actual

routerLinkActive agrega una clase al enlace cuando su ruta es la actual. Con esa clase se cambia el aspecto:

<a [routerLink]="['']" routerLinkActive="active"
   [routerLinkActiveOptions]="{ exact: true }">Home</a>
<a [routerLink]="['hero']" routerLinkActive="active"
   [routerLinkActiveOptions]="{ exact: true }">Hero</a>
nav a.active {
  color: red;
}

Por qué exact: true. Sin esa opción, Angular marca el enlace si la dirección empieza con la suya. La dirección de Home es '', y todas las direcciones empiezan con ''. Resultado: estando en /hero, Home y Hero quedan marcados a la vez. Con exact: true se exige que la dirección sea exactamente esa.

Estás en Home sin exact Home con exact Hero
/ marcado marcado no
/hero marcado no sí

Rutas que no existen: **

La última ruta de la lista, '**', atrapa cualquier dirección que no coincidió con ninguna. En lugar de mostrar una página, puede redirigir:

export const routes: Routes = [
  { path: '', loadComponent: () => import('./pages/counter/counter-page.component').then((m) => m.CounterPageComponent) },
  { path: 'hero', loadComponent: () => import('./pages/hero/hero-page.component').then((m) => m.HeroPageComponent) },
  { path: 'dragonball', loadComponent: () => import('./pages/dragonball/dragonball-page.component').then((m) => m.DragonballPageComponent) },
  { path: 'dragonball-super', loadComponent: () => import('./pages/dragonball-super/dragonball-super-page.component').then((m) => m.DragonballSuperPageComponent) },
  { path: '**', redirectTo: '' },
]

Si alguien escribe /algo-que-no-existe, termina en /. Va al final, porque el enrutador usa la primera ruta que coincide (ver el recorrido en el Módulo A3).

El nuevo control flow: @for, @if y @let

Las plantillas ahora tienen bloques propios, escritos con @ y llaves. Se usan para repetir y para mostrar según una condición (los legacy *ngFor y *ngIf están en el Módulo A1).

<ul>
  @for (character of sortedCharacters(); track character.id; let idx = $index) {
    @let isOverpowered = character.powerLevel !== null && character.powerLevel > 9000;

    <li>
      {{ idx + 1 }} - <strong>{{ character.name }}</strong>
      @if (isOverpowered) {
        <strong class="overpowered-text">Is Over 9000!</strong>
      }
    </li>
  }
</ul>
Pieza Qué hace
@for (x of lista; ...) { } Repite el bloque una vez por elemento
track character.id Obligatorio. Dice qué identifica a cada elemento (ver abajo)
let idx = $index Guarda la posición (0, 1, 2…) en una variable. Por eso se muestra idx + 1
@let nombre = expresión; Declara una variable local y evita repetir la misma expresión
@if (condición) { } Muestra el bloque sólo si la condición es verdadera

track: para que Angular no dibuje todo de nuevo. Cuando la lista cambia, Angular necesita saber qué elemento es cuál. Con track character.id, si se agrega un personaje, Angular reutiliza los elementos que ya estaban en pantalla y sólo crea el nuevo. Sin una identidad, tendría que tirar todo y dibujarlo otra vez. El valor de track debe ser único por elemento: un id, no la posición.

@let: no repetir la condición. isOverpowered se calcula una vez por personaje y se usa dos veces (en el @if y, si quieres, en una clase). Es una variable de la plantilla: no existe en la clase y no se puede usar fuera del bloque donde se declara (aquí, dentro del @for).

Clases según una condición: alternativas a ngClass

Para poner una clase sólo cuando se cumple algo, no hace falta ngClass. El enlace de clase (class binding) lo hace directo:

<li [class.overpowered]="isOverpowered" [class.weakling]="isYamcha">
  • [class.overpowered]="isOverpowered" pone la clase overpowered si isOverpowered es verdadero, y la quita si es falso. Las demás clases del elemento no se tocan.
  • Para varias clases a la vez, también se puede pasar un objeto: [class]="{ big: on, bold: !on }".
  • Lo mismo para estilos sueltos, en lugar de ngStyle: [style.color]="'red'" o con unidad, [style.width.px]="50".
Quieres Con
Una clase según una condición [class.nombre]="condición"
Varias clases según condiciones [class]="{ a: x, b: y }"
Un estilo con un valor [style.color]="valor"
Un estilo con unidad (px, %, rem) [style.width.px]="50"

Estos enlaces no necesitan importar nada: vienen con Angular. ngClass y ngStyle siguen existiendo, pero hay que importarlos desde @angular/common, y para estos casos no aportan nada.

Una lista que se ordena sola

La página dragonball junta lo anterior con lo de signals (Módulo A3):

characters: WritableSignal<Character[]> = signal<Character[]>([ /* ... */ ])

sortedCharacters: Signal<Character[]> = computed(() =>
  [...this.characters()].sort((a, b) => (b.powerLevel ?? 0) - (a.powerLevel ?? 0)),
)

addCharacter(): void {
  // ...
  this.characters.update((current) => [...current, newCharacter])
  this.resetFields()
}
  • La plantilla recorre sortedCharacters(), no characters(): la lista ordenada es un computed, así que se recalcula sola cuando se agrega un personaje y el nuevo aparece en su lugar.
  • sort modifica el arreglo que recibe; por eso se ordena una copia ([...this.characters()]) y no el original.
  • Para agregar se crea una lista nueva con update; un push no avisaría.
  • powerLevel puede ser null (campo vacío). El ?? 0 dice "si es null, cuéntalo como 0" para poder comparar.

Leer lo que escribe el usuario, sin formularios. Cada <input> guarda lo que se escribe en un signal:

<input type="text" #nameInput [value]="characterName()"
       (change)="characterName.set(nameInput.value)"
       (input)="characterName.set(nameInput.value)">
<input type="number" #powerLevelInput [value]="characterPowerLevel() ?? ''"
       (change)="characterPowerLevel.set(powerLevelInput.value === '' ? null : +powerLevelInput.value)">
  • #nameInput es una variable de plantilla: una referencia al elemento. nameInput.value es el texto que tiene en ese momento.
  • [value]="characterName()" hace que el campo muestre el signal. Por eso al hacer characterName.set('') el campo se vacía.
  • input o change. input ocurre con cada tecla; change ocurre al terminar (cuando el campo pierde el foco o se pulsa Enter). El nombre usa los dos para que {{ characterName() }} se vea actualizado mientras se escribe.
  • +powerLevelInput.value convierte el texto en número (un <input> siempre entrega texto). Ojo: un campo vacío da 0, no null, y 0 es un poder válido. Por eso se comprueba antes si el campo está vacío y, en ese caso, se guarda null.
  • [value]="characterPowerLevel() ?? ''" muestra null como un campo vacío.
  • Esa conversión está escrita en la plantilla porque esta página es un solo componente, pero no es lo ideal: la lógica se repite en cada evento y no se puede probar. En character-add (más abajo) se hace de otra forma.

Dividir una página en componentes: input() y output()

La página dragonball hacía todo en un solo componente. dragonball-super es la misma página repartida en tres:

DragonballSuperPageComponent    la lista de personajes y agregar a la lista
├── CharacterAddComponent       el formulario: nombre, poder y el botón Add
└── CharacterListComponent      sólo muestra la lista

La regla es que cada componente es dueño de lo suyo: quien tiene el dato lo guarda, y los demás lo reciben o avisan.

El padre le pasa datos al hijo: input().

export class CharacterListComponent {
  characters = input.required<Character[]>()
  listName = input<string>()
}
<dragonball-character-list
  listName="List of DBZ Characters"
  [characters]="sortedCharacters()" />
  • input.required<T>() es obligatorio: si el padre no lo manda, no compila. input<T>() es opcional, y su valor es undefined si no lo mandan (por eso su tipo es T | undefined).
  • Se lee como cualquier signal, con paréntesis: characters().
  • Es de sólo lectura: el hijo no tiene set. Escribir characters.set(...) sobre un input no compila.
  • En el padre, sin corchetes es un texto fijo (listName="...") y con corchetes es una expresión ([characters]="sortedCharacters()"). Se pasa el valor (con paréntesis), no el signal.

El hijo le avisa al padre: output().

export class CharacterAddComponent {
  name = signal('')
  powerLevel = signal<number | null>(null)

  newCharacter = output<Omit<Character, 'id'>>()

  addCharacter(): void {
    if (!this.name() || this.powerLevel() === null) {
      return
    }
    this.newCharacter.emit({ name: this.name(), powerLevel: this.powerLevel() })
    this.resetFields()
  }
}
<dragonball-character-add (newCharacter)="addCharacter($event)" />
// en la página
addCharacter(newCharacter: Omit<Character, 'id'>): void {
  const updatedCharacter = { id: this.characters().length + 1, ...newCharacter }
  this.characters.update((list) => [...list, updatedCharacter])
}
  • output<T>() declara un evento propio del componente y emit(valor) lo dispara. En el padre se escucha con paréntesis, y $event es lo que se pasó a emit.
  • Omit<Character, 'id'> es el tipo Character sin la propiedad id. El hijo no sabe qué id toca; lo pone el padre, que es el que conoce la lista.
  • No le pongas a un output el nombre de un evento del navegador (click, input, change): se presta a confusión.

Quién es dueño de qué:

Dato o lógica Dónde Por qué
Nombre y poder mientras se escriben, validar y vaciar el formulario character-add Es asunto del formulario; el padre no lo necesita
La lista, ordenarla, agregar con su id La página (o un servicio, ver más abajo) Sólo ella conoce la lista
Mostrar la lista character-list No guarda nada: sólo recibe

El recorrido de un personaje nuevo:

El usuario escribe          → character-add guarda en sus signals (name, powerLevel)
Pulsa Add                   → valida y emite newCharacter
   └─ la página recibe $event → characters.update(...) y sortedCharacters se recalcula
        └─ character-list recibe la lista nueva por su input y se redibuja

Si el hijo necesita cambiar un dato del padre. Un input no se puede cambiar desde el hijo. Hay dos caminos: un output por cada cambio (el padre hace el set) o model(), que es un input que el hijo sí puede cambiar y que se enlaza en los dos sentidos con [(valor)]="signal". Aquí no hace falta, porque el formulario es del hijo y al padre sólo le llega el resultado.

Estilos de un hijo. Los estilos de la página no llegan a la plantilla de sus hijos. Las clases overpowered y weakling se usan dentro de la lista, así que están en character-list-component.css, y cualquier página que use <dragonball-character-list> las obtiene. Existe ::ng-deep para forzar la entrada desde el padre, pero está marcado como obsoleto y aquí no hace falta.

La conversión va en el componente, no en la plantilla. Fíjate en cómo character-add maneja el campo del poder:

<input type="number" #powerLevelInput [value]="powerLevel() ?? ''"
       (change)="characterPowerLevelChanged(powerLevelInput.value)"
       (input)="characterPowerLevelChanged(powerLevelInput.value)">
characterPowerLevelChanged(value: string): void {
  this.powerLevel.set(value === '' ? null : +value)
}

La plantilla sólo pasa el texto del campo, tal cual; el método decide qué hacer con él: lo convierte en número y, si está vacío, guarda null (porque +'' sería 0, un poder real). Así la regla vive en un solo sitio, se escribe una vez aunque haya dos eventos (input y change) y se puede probar sin la plantilla. Una plantilla debería leer datos y llamar métodos; las conversiones y validaciones van en la clase.

Con null bien guardado, la validación del botón es simple (!this.name() || this.powerLevel() === null), un 0 escrito a propósito sí cuenta, y el título puede mostrar Power: con un @if (powerLevel() !== null) sin comprobaciones extra. El título se actualiza mientras se escribe porque el campo usa (input) además de (change).

Servicios: estado que sobrevive a la navegación

El problema. Abre /dragonball, agrega un personaje, ve a Home y vuelve: el personaje ya no está. Al navegar, Angular destruye el componente de la página, y con él sus signals. Si una lista tiene que seguir viva al cambiar de página, no puede guardarse en el componente: necesita un dueño que dure más que las páginas. Ese dueño es un servicio.

@Injectable({
  providedIn: 'root',
})
export class DragonballService {
  characters: WritableSignal<Character[]> = signal<Character[]>([
    { id: 1, name: 'Goku', powerLevel: 9001 },
    { id: 2, name: 'Vegeta', powerLevel: 8500 },
  ])

  addCharacter(newCharacter: Omit<Character, 'id'>): void {
    const updatedCharacter = { id: this.characters().length + 1, ...newCharacter }
    this.characters.update((list) => [...list, updatedCharacter])
  }
}

La página dragonball-super ya no guarda la lista: se la pide al servicio.

export class DragonballSuperPageComponent {
  public dragonballService = inject(DragonballService)

  characters: WritableSignal<Character[]> = this.dragonballService.characters

  sortedCharacters: Signal<Character[]> = computed(() =>
    [...this.characters()].sort((a, b) => (b.powerLevel ?? 0) - (a.powerLevel ?? 0)),
  )

  addCharacter(newCharacter: Omit<Character, 'id'>): void {
    this.dragonballService.addCharacter(newCharacter)
  }
}
  • @Injectable({ providedIn: 'root' }) convierte la clase en un servicio. Con 'root' hay una sola instancia para toda la aplicación (un singleton): se crea la primera vez que alguien la pide y vive mientras la aplicación siga abierta.
  • inject(DragonballService) le pide esa instancia a Angular. La forma antigua era el constructor (constructor(private dragonballService: DragonballService) {}); las dos funcionan, pero inject() es la actual y la que se recomienda en componentes standalone.
  • El reparto. El servicio guarda los datos y las operaciones sobre ellos (addCharacter). La página se queda con lo que es de la pantalla: ordenar la lista para mostrarla y pasar los eventos del formulario.

Por qué el estado sobrevive. No es magia: es una cuestión de cuánto vive cada cosa.

Dónde está el dato Instancias Cuánto vive
En el componente (characters = signal()) Una por cada componente Hasta que se destruye (al navegar)
Servicio con providers: [X] en un componente Una por cada componente Muere con el componente
Servicio con providedIn: 'root' Una para toda la aplicación Hasta cerrar o recargar la página

En el proyecto se ve la diferencia: /dragonball guarda su lista en el componente y se reinicia al volver; /dragonball-super la guarda en el servicio y se conserva. Un servicio en providers de un componente no sirve para compartir: cada componente recibe su propia copia, que se destruye con él.

Ojo: así todavía no es persistencia. El servicio guarda el estado en memoria. Si recargas la página (F5) o cierras la pestaña, la aplicación arranca de cero y la lista vuelve a Goku y Vegeta. Lo más exacto es decir "estado compartido que dura mientras la aplicación esté abierta". Para que sobreviva a una recarga hay que guardarlo fuera de la aplicación:

Opción Dura Sirve para
localStorage Hasta que se borre Datos que deben volver al abrir el navegador
sessionStorage Mientras dure la pestaña Datos temporales de una sesión
IndexedDB Hasta que se borre Muchos datos u objetos grandes
Backend (API) Siempre Datos que se comparten entre dispositivos o personas

¿Y un signal store? Un signal store (por ejemplo el SignalStore de NgRx) sirve para organizar el estado compartido: es un servicio con más estructura. Pero también vive en memoria, así que por sí solo tampoco persiste. La forma habitual es combinarlo con una de las opciones de arriba: el estado vive en un servicio o store, y un effect lo copia al localStorage cada vez que cambia. Eso es lo que se hace a continuación.

Efectos y localStorage: persistir de verdad

Qué es un efecto secundario. Una función normal recibe datos y devuelve un resultado. Un efecto secundario (side effect) es cuando, además, hace algo fuera de sí misma, en el mundo exterior: escribir en localStorage, mostrar algo en la consola, pedir datos a un servidor, tocar el DOM a mano.

effect() es la herramienta de Angular para eso: una función que se ejecuta sola cada vez que cambia algún signal que lee.

effect(() => {
  localStorage.setItem('characters', JSON.stringify(this.characters()))
})

Como lee this.characters(), Angular la vuelve a ejecutar cada vez que esa lista cambia. No hay que llamarla a mano.

computed o effect. Se parecen, pero sirven para cosas distintas:

Quieres... Usa Ejemplo
Un valor que sale de otros signals computed total = computed(() => precio() * cantidad())
Una acción cuando algo cambia effect Guardar el total en localStorage

computed es puro: calcula y devuelve, sin tocar nada de afuera, y se lee como un signal. effect no devuelve nada: hace algo. Si te descubres usando un effect para calcular un valor, lo que querías era un computed.

El servicio con persistencia. Así queda DragonballService:

const loadFromLocalStorage = (): Character[] => {
  const characters: string | null = localStorage.getItem('characters')
  return characters
    ? JSON.parse(characters)
    : [
        { id: 1, name: 'Goku', powerLevel: 9001 },
        { id: 2, name: 'Vegeta', powerLevel: 8500 },
      ]
}

@Injectable({
  providedIn: 'root',
})
export class DragonballService {
  characters: WritableSignal<Character[]> = signal<Character[]>(loadFromLocalStorage())

  addCharacter(newCharacter: Omit<Character, 'id'>): void {
    const updatedCharacter = { id: this.characters().length + 1, ...newCharacter }
    this.characters.update((list) => [...list, updatedCharacter])
  }

  saveToLocalStorage = effect(() => {
    localStorage.setItem('characters', JSON.stringify(this.characters()))
  })
}

Son dos mitades que se complementan:

Al arrancar        → loadFromLocalStorage() lee lo guardado (o usa la lista inicial)
Cada vez que cambia → el effect guarda la lista completa en localStorage
  • Lo guardado vuelve tras recargar. Si agregas un personaje y recargas la página (F5), el servicio nace de nuevo, lee el localStorage y la lista sigue ahí, con Gohan incluido.
  • El efecto corre una vez al crearse. Por eso la lista inicial se escribe en localStorage aunque nadie haya agregado nada. Después corre con cada cambio.
  • Sólo reacciona a lo que lee. Si dentro del efecto no se lee un signal, sus cambios no lo disparan.
  • Se limpia solo. Un efecto creado dentro de un servicio o componente se detiene cuando su dueño se destruye. El servicio es de la raíz, así que vive mientras viva la aplicación.
  • Un efecto, una responsabilidad. Este sólo guarda. Los console.log que puedas poner dentro sirven para ver cuándo corre, pero en una aplicación real se quitan.

Cómo funciona localStorage:

Detalle Qué significa
Sólo guarda texto Por eso se usa JSON.stringify al guardar y JSON.parse al leer
Es síncrono y de poco espacio Sirve para cosas pequeñas, no para grandes cantidades de datos
Es del navegador y del sitio Cada navegador y cada dirección tiene el suyo: no viaja entre dispositivos
No es seguro Cualquier script de la página o cualquiera con las herramientas del navegador puede leerlo

Nunca guardes contraseñas ni datos personales sensibles en localStorage.

Un riesgo: datos dañados o manipulados. Lo que se lee del localStorage es información que no controlas: puede estar rota (un error, una versión anterior de la aplicación que guardaba otra forma) o puede haber sido modificada a propósito, porque cualquiera con las herramientas del navegador puede editarla. El servicio de arriba no se protege. Dos ejemplos de lo que podría llegar:

  • Texto roto (por ejemplo {roto). JSON.parse lanza un error al crear el servicio, y la parte de la aplicación que lo usa deja de funcionar. El texto dañado sigue en el localStorage hasta que alguien lo borra a mano.
  • JSON válido, pero que no es una lista (por ejemplo {}). JSON.parse no falla, pero el error aparece después, cuando la página intenta recorrer o copiar algo que no es una lista.

Son sólo ejemplos. Lo importante es la regla: normalmente se valida todo lo que viene del localStorage antes de usarlo, y no sólo que no esté roto. También que tenga la forma esperada (una lista, y que cada personaje tenga id numérico, name de texto y powerLevel numérico o null) y, si hace falta, valores razonables. Un texto que se puede leer sin error puede seguir siendo inválido o haber sido manipulado.

En una aplicación real conviene leerlo con un try/catch, comprobar la forma de lo leído y, si algo falla, volver a la lista inicial:

const isCharacter = (value: unknown): value is Character => {
  const c = value as Character
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof c.id === 'number' &&
    typeof c.name === 'string' &&
    (c.powerLevel === null || typeof c.powerLevel === 'number')
  )
}

const loadFromLocalStorage = (): Character[] => {
  try {
    const characters = localStorage.getItem('characters')
    if (characters) {
      const parsed = JSON.parse(characters)
      if (Array.isArray(parsed) && parsed.every(isCharacter)) {
        return parsed
      }
    }
  } catch {
    // datos dañados: se ignoran y se usa la lista inicial
  }
  return [
    { id: 1, name: 'Goku', powerLevel: 9001 },
    { id: 2, name: 'Vegeta', powerLevel: 8500 },
  ]
}

Así, un texto roto, una forma distinta o un personaje a medias terminan en la lista inicial, y el effect la guarda encima la primera vez que corre, dejando datos buenos en el localStorage.

Validar aquí no es seguridad. Esta validación protege a la aplicación de romperse con datos raros; no impide que el usuario modifique sus propios datos, porque están en su navegador. Si un dato importa de verdad (precios, permisos, saldos), la verificación definitiva se hace en el servidor.

Y con SSR (el servidor arma la página) localStorage es del navegador y no siempre está disponible en el servidor, así que habría que protegerlo; en esta aplicación, que es una SPA, no hace falta.

Errores comunes

  • Usar <a href="/hero"> en lugar de routerLink: la página se recarga y se pierde el estado de la aplicación.
  • Olvidar imports: [RouterLink] en un componente standalone: el enlace no funciona.
  • Poner routerLinkActive en el enlace de Home sin exact: true: Home queda marcado en todas las páginas.
  • Escribir la ruta '**' antes de otras: las que vienen después nunca se alcanzan.
  • Usar la posición ($index) como track: al reordenar la lista, Angular cree que cada posición sigue siendo el mismo elemento.
  • Olvidar el track en un @for: no compila.
  • Ordenar con sort() el arreglo del signal, sin copiarlo.
  • Convertir con +input.value y no darse cuenta de que el vacío se vuelve 0.
  • Escribir conversiones o validaciones dentro de la plantilla (valor === '' ? null : +valor): se repiten en cada evento y no se pueden probar. Van en un método del componente, que reciba el texto del campo.
  • Guardar en un componente algo que debe sobrevivir a la navegación: el componente se destruye al cambiar de página y se lleva sus signals.
  • Poner el servicio en el providers de un componente esperando compartirlo entre páginas: cada componente crea su propia copia.
  • Creer que el estado de un servicio sobrevive a recargar la página: vive en memoria, y la recarga lo borra, a menos que se guarde aparte.
  • Usar un effect para calcular un valor: eso es un computed. El effect es para acciones (guardar, avisar, registrar).
  • Guardar contraseñas o datos sensibles en localStorage: cualquiera con acceso al navegador puede leerlos.
  • Confiar en lo que viene del localStorage sin validarlo: puede estar roto o haber sido manipulado. Hay que protegerse del texto roto y comprobar que tiene la forma esperada.
  • Escribir algo.set(...) sobre un input(): no compila, los inputs son de sólo lectura. Usa un output (o model()).
  • Pasar el signal en lugar de su valor a un input ([characters]="lista" en vez de [characters]="lista()"): el tipo no coincide.
  • Esperar que el CSS de la página llegue a la plantilla de un componente hijo: las clases que usa el hijo van en el CSS del hijo.

Resumen

routerLink cambia de página sin recargar y routerLinkActive marca la actual; Home necesita exact: true. La ruta '**' al final redirige lo que no existe. @for repite (con track obligatorio, mejor un id), @if muestra según una condición y @let evita repetir una expresión. Para clases y estilos según una condición bastan [class.x] y [style.x], sin ngClass ni ngStyle. Una lista ordenada es un computed sobre una copia, y lo que se escribe en un <input> se guarda en un signal con (input) o (change). Al repartir una página en componentes, el padre pasa datos con input() (sólo lectura) y el hijo avisa con output(); cada componente es dueño de lo suyo. Lo que debe sobrevivir a la navegación se guarda en un servicio providedIn: 'root', que es único y no se destruye con las páginas. Vive en memoria, así que una recarga lo borra; para persistirlo, un effect (una acción que corre cuando cambia un signal que lee) lo guarda en localStorage, y el servicio lo lee al arrancar.

Preguntas de entrevista (senior)

1. ¿Qué diferencia hay entre <a href> y <a routerLink> en una SPA?

Respuesta:

Con href, el navegador hace una petición nueva: descarga el documento, vuelve a arrancar Angular y se pierde el estado en memoria. Con routerLink, Angular intercepta el clic, cambia la ruta con el enrutador y dibuja el componente que corresponde dentro de <router-outlet />, sin recargar. Además Angular escribe el href real en la etiqueta, así que se conserva el comportamiento normal del navegador (abrir en otra pestaña, copiar el enlace) y los buscadores ven un enlace válido.

2. En una barra de navegación, el enlace a Home queda siempre marcado como activo. ¿Por qué y cómo se arregla?

Respuesta:

routerLinkActive marca el enlace cuando la dirección actual empieza con la del enlace. La de Home es la vacía (''), y todas las direcciones empiezan con ella. Se arregla con [routerLinkActiveOptions]="{ exact: true }" en ese enlace, que exige coincidencia exacta. En los demás enlaces no suele hacer falta; en los que tienen rutas hijas conviene dejarlo sin exact para que el padre siga marcado.

3. ¿Por qué @for exige track y qué conviene poner?

Respuesta:

Porque Angular necesita identificar cada elemento para saber, cuando la lista cambia, cuáles reutilizar, cuáles crear y cuáles mover. Con una identidad estable (un id) agregar o reordenar sólo toca los elementos afectados y conserva el estado de los demás (por ejemplo, el texto escrito en un campo de cada fila). Con $index como identidad, al insertar al principio todas las posiciones cambian de dueño y se rehace casi todo, o se mezcla el estado entre filas. La regla: track item.id si hay un identificador único; $index sólo si la lista no cambia de orden.

4. ¿Cómo reemplazas ngClass y ngStyle en una plantilla actual?

Respuesta:

Con enlaces nativos de Angular: [class.nombre]="condición" para una clase, [class]="{ a: x, b: y }" para varias, [style.color]="valor" para un estilo y [style.width.px]="n" cuando lleva unidad. No necesitan importar nada, son más cortos y no dejan clases de más: las clases fijas del elemento (class="fixed") se conservan junto a las condicionales. ngClass y ngStyle siguen funcionando, pero obligan a importar la directiva y para estos casos no aportan ventaja.

5. Quieres mostrar una lista ordenada que cambia cuando el usuario agrega elementos. ¿Cómo la modelas?

Respuesta:

Los datos viven en un signal y la lista ordenada es un computed que lee ese signal. Dentro del computed se ordena una copia ([...lista()].sort(...)) porque sort modifica el arreglo original sin avisar. Para agregar se crea una lista nueva con update ([...actual, nuevo]); un push cambiaría el arreglo por dentro y el signal no se enteraría. La plantilla recorre el computed, y al agregar un elemento el computed se recalcula solo y el elemento aparece en su lugar, sin código extra de sincronización.

6. ¿Cómo se comunican un componente padre y uno hijo en Angular actual?

Respuesta:

Los datos bajan con input() y los avisos suben con output(). Un input es un signal de sólo lectura: el hijo lo lee (valor()) pero no lo cambia. Puede ser obligatorio (input.required) u opcional. Un output declara un evento propio; el hijo lo dispara con emit(valor) y el padre lo escucha con (evento)="manejar($event)". Cuando el hijo necesita editar un dato del padre hay dos caminos: un output por cada cambio, donde el padre hace el set, o model(), un input que el hijo también puede cambiar y que se enlaza en los dos sentidos con [(valor)]. Con esto el dato siempre tiene un solo dueño y el flujo es fácil de seguir.

7. Al separar una página en componentes, ¿dónde va la lógica de "agregar un elemento"?

Respuesta:

Se reparte según quién es dueño de cada cosa. La parte del formulario (leer lo que escribe el usuario, validar y vaciar los campos) va en el componente del formulario, con sus propios signals; el padre no necesita ver cada tecla. La parte de la lista (agregar, ordenar, asignar el id) va en quien guarda la lista: la página, o un servicio si la comparten varias páginas. El formulario emite un solo evento con el elemento listo, sin el id (Omit<Character, 'id'>), y el dueño de la lista lo agrega con update y una lista nueva. Así el formulario se reutiliza con otra lista y la lista se muestra sin saber de dónde vienen los datos. Sólo conviene dejar el estado del formulario en el padre si el padre necesita verlo mientras se escribe.

8. ¿Por qué el estado de un servicio sobrevive a la navegación y el de un componente no? ¿Es persistencia?

Respuesta:

Porque tienen distinta vida. Al navegar, el enrutador destruye el componente de la página y con él sus propiedades y signals. Un servicio con providedIn: 'root' es un singleton del inyector raíz, que vive tanto como la aplicación, así que su estado sigue ahí cuando se crea la página siguiente. Si en cambio se declara en el providers de un componente, cada instancia del componente crea su propia copia y la destruye al irse, y no sirve para compartir. No es persistencia: es estado en memoria, y una recarga o cerrar la pestaña lo pierde. Para persistir de verdad hay que escribirlo en localStorage, sessionStorage, IndexedDB o un backend, normalmente con un effect que copia el estado cada vez que cambia. Un signal store ayuda a organizar ese estado compartido, pero tampoco persiste por sí solo.

9. ¿Qué es un efecto secundario y cuándo usas effect() en lugar de computed()?

Respuesta:

Un efecto secundario es una acción que sale de la función y toca algo externo: escribir en localStorage, registrar en consola, llamar a un servidor o tocar el DOM. effect() ejecuta una función cada vez que cambia algún signal que lee (corre una vez al crearse y después con cada cambio), y se limpia solo cuando su dueño se destruye. computed() sirve para calcular un valor derivado: es puro, devuelve un signal y no debe tocar nada de afuera. La regla es: si necesitas un valor, computed; si necesitas una acción, effect. Usar un effect para derivar un valor obliga a escribirlo en otro signal y a mantener ambos sincronizados, que es justo lo que computed evita. Para persistir, un effect en el servicio que guarda el estado en localStorage (con JSON.stringify) y un valor inicial que lo lee al arrancar (con JSON.parse y un try/catch), sin guardar nunca datos sensibles.


Módulo A5: Desplegar en Coolify

Este módulo no sale de una clase del curso: cuenta cómo se publicó 02-bases en internet, en https://angular-basics.reskyon.com, con Coolify. Los archivos están en 02-bases/: el Dockerfile, el nginx.conf y el .dockerignore. El README.md del proyecto tiene la misma configuración en forma de lista de pasos.

El problema que resuelve

ng serve sirve para trabajar, pero sólo vive en tu computadora y compila en memoria cada vez que guardas. Para que otra persona abra la aplicación hace falta construirla una vez (ng build) y dejar los archivos resultantes en un servidor que los entregue por HTTPS, con un dominio propio.

Qué se publica en realidad

Una SPA de Angular, una vez construida, son archivos estáticos: un index.html, varios .js y .css y lo que haya en public/. Quedan en dist/bases/browser/. No hay un programa de Angular corriendo en el servidor: el servidor sólo entrega archivos, y toda la lógica (rutas, signals, servicios) corre en el navegador de quien visita la página.

Por eso dist/ no se sube al repositorio (está en el .gitignore): Coolify ejecuta el build en cada despliegue.

El camino de un despliegue y de una visita

git push a main
      |
      v
Coolify clona Reskyon/angular-ufh y entra en /02-bases
      |
      v
Etapa 1: node:24-alpine   npm ci + npm run build  ->  dist/bases/browser
      |
      v
Etapa 2: nginx:alpine     sólo los archivos estáticos, puerto 80
      |
      v
Contenedor en marcha


Visita:  navegador -> Cloudflare -> Traefik (proxy de Coolify) -> Nginx

Cloudflare resuelve el dominio y protege el tráfico; Traefik recibe la petición en el servidor, pone el certificado HTTPS y la manda al contenedor correcto; Nginx entrega los archivos.

Construir en dos etapas: el Dockerfile

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/bases/browser /usr/share/nginx/html
EXPOSE 80

La primera etapa tiene Node y todas las dependencias, y sólo sirve para compilar. La segunda parte de una imagen limpia de Nginx y copia sólo el resultado. La imagen final no tiene Node, ni node_modules, ni el código fuente: es más chica y tiene menos cosas que atacar.

Se copia primero package.json y package-lock.json y después el resto para aprovechar la caché de Docker: si sólo cambió un componente, no reinstala las dependencias.

Nginx y las rutas de Angular

Las rutas de Angular, como /dragonball-super, existen sólo en el navegador: las resuelve el enrutador de Angular dentro de <router-outlet />. En el servidor no hay ningún archivo dragonball-super. Si recargas esa página, el navegador se la pide a Nginx, y sin configuración extra Nginx responde que no existe.

La solución es el fallback: si el archivo pedido no existe, se entrega index.html y Angular se encarga de la ruta.

location / {
    add_header Cache-Control "no-cache";
    try_files $uri $uri/ /index.html;
}

Es la misma idea que la ruta ** del enrutador, pero un nivel antes: ** atrapa lo que Angular no conoce; try_files atrapa lo que el servidor no conoce y se lo pasa a Angular. Donde la comparación deja de ser exacta: con el fallback el servidor responde siempre con éxito, aunque la ruta no exista; quien muestra la página de "no encontrado" es la ruta ** de Angular.

Los .js y .css llevan un código en el nombre que cambia en cada build, así que se guardan en caché un año. index.html no se guarda nunca, para que cada visita vea el último despliegue.

Por qué no el modo por defecto de Coolify

Coolify trae Nixpacks, que detecta que es un proyecto de Node y arma la imagen solo. El primer intento falló: Nixpacks instaló Node 22.11.0 y Angular 22 pide Node 22.22.3, 24.15.0 o más nuevo, así que ng build se detuvo antes de compilar. La variable NIXPACKS_NODE_VERSION sólo elige la versión mayor dentro de un paquete fijo que va atrasado. Con un Dockerfile propio la versión de Node queda escrita en el repositorio.

La configuración en Coolify

Campo Valor Por qué
Repositorio Reskyon/angular-ufh El repositorio entero, no el enlace tree/main/...
Rama main Cada push a main despliega
Build pack Dockerfile Para controlar la versión de Node
Base directory /02-bases Coolify trabaja dentro de esa carpeta
Dockerfile location /Dockerfile Relativo a la base directory
Ports exposes 80 El puerto donde escucha Nginx dentro del contenedor
Dominio https://angular-basics.reskyon.com Traefik pide el certificado para este nombre

Pendientes opcionales: poner 02-bases/** en Watch paths para que un push que sólo toca otras carpetas no redespliegue, y borrar la variable NIXPACKS_NODE_VERSION, que quedó del primer intento y ya no se usa.

DNS y HTTPS con Cloudflare

En Cloudflare hay un registro A llamado angular-basics que apunta a <SERVER_IP>, con el proxy activado (nube naranja). El modo SSL de Cloudflare es Full (strict): exige que el servidor tenga un certificado válido, y ese certificado lo pide Traefik a Let's Encrypt cuando el contenedor ya está corriendo.

Si el despliegue falla, no hay contenedor ni ruta para el dominio. Traefik responde con un certificado propio que nadie reconoce y Cloudflare muestra Invalid SSL certificate. El arreglo es corregir el despliegue, no tocar Cloudflare.

¿Se puede atacar el servidor desde el formulario?

No. La página no tiene backend: el formulario de dragonball-super guarda los personajes en el localStorage del navegador de cada visitante y nunca envía nada al servidor. Si alguien escribe código raro, queda en su propio navegador, y además Angular escapa lo que se muestra con {{ }}. El contenedor sólo tiene Nginx sirviendo archivos, sin claves ni acceso a otros servicios. Esto cambia cuando la aplicación empiece a llamar a una API: ahí la validación del servidor es la que importa.

Errores comunes

  • Pegar en Coolify la URL tree/main/02-bases. Va el repositorio y la carpeta se indica en Base directory.
  • Subir dist/ al repositorio. Se genera en cada despliegue.
  • Repetir la carpeta en las rutas: con base directory /02-bases, el Dockerfile es /Dockerfile, no /02-bases/Dockerfile.
  • Olvidar el fallback de Nginx: la página carga desde el inicio, pero al recargar una ruta da "no encontrado".
  • Crear el dominio en Coolify antes del registro DNS: Let's Encrypt no puede validar un nombre que no existe.
  • Buscar el problema en Cloudflare cuando aparece un error de certificado: casi siempre el despliegue falló y no hay contenedor.

Resumen

  • Una SPA construida son archivos estáticos; el servidor sólo los entrega.
  • El Dockerfile compila con Node en una etapa y sirve con Nginx en otra.
  • try_files ... /index.html deja que Angular resuelva sus rutas al recargar.
  • En Coolify: repositorio entero, base directory /02-bases, build pack Dockerfile, puerto 80.
  • Cloudflare en modo estricto necesita que el despliegue funcione para que haya certificado.

Preguntas de entrevista (senior)

1. ¿Por qué una SPA necesita un fallback a index.html en el servidor?

Respuesta:

Porque las rutas de una SPA las resuelve el enrutador en el navegador, no el servidor. Al navegar con enlaces, Angular cambia la URL sin pedir nada al servidor. Pero si el usuario recarga o abre un enlace directo como /dragonball-super, el navegador pide ese camino al servidor, que no tiene ningún archivo con ese nombre. El fallback (try_files $uri $uri/ /index.html en Nginx) entrega index.html para cualquier camino que no sea un archivo real; Angular arranca, lee la URL y muestra la página. La consecuencia es que el servidor responde con éxito incluso para rutas inexistentes, así que la página de "no encontrado" la tiene que poner Angular con la ruta **. Si se necesita que los buscadores reciban un "no encontrado" real, hace falta renderizado en el servidor o prerenderizado.

2. ¿Qué ganas con un build en dos etapas?

Respuesta:

Separas las herramientas de construcción de lo que se ejecuta. La primera etapa necesita Node, el CLI y cientos de dependencias de desarrollo; la segunda sólo necesita un servidor de archivos. Copiando únicamente dist/bases/browser a una imagen de Nginx, la imagen final pesa mucho menos, arranca más rápido y no lleva compiladores, node_modules ni código fuente, así que hay menos cosas que puedan tener una vulnerabilidad. Además, copiar primero los archivos de dependencias y luego el código permite que Docker reutilice la capa de npm ci mientras package-lock.json no cambie.

3. El despliegue falló y el dominio muestra un error de certificado de Cloudflare. ¿Qué miras primero?

Respuesta:

El despliegue, no Cloudflare. Con el modo Full (strict), Cloudflare exige un certificado válido en el servidor. Ese certificado lo emite Traefik cuando existe un contenedor con ese dominio. Si el build falló, no hay contenedor ni ruta: Traefik contesta con su certificado por defecto, que no es de confianza, y Cloudflare lo rechaza. Primero se revisa el log del despliegue en Coolify y que el contenedor esté corriendo. Después, que el registro DNS exista, porque sin él Let's Encrypt no puede validar el dominio. Bajar el modo SSL de Cloudflare taparía el síntoma y dejaría el tramo entre Cloudflare y el servidor sin verificar.

4. ¿Por qué fijar la versión de Node en el Dockerfile en lugar de dejar que la plataforma la elija?

Respuesta:

Porque cada versión de Angular exige un rango concreto de Node, y si la plataforma elige la versión, el build depende de algo que no está en el repositorio. Aquí Nixpacks instaló un Node 22 más viejo que el mínimo de Angular 22 y el build se cortó. Con FROM node:24-alpine la versión queda escrita junto al código, el mismo Dockerfile se puede probar en local con docker build, y cambiar de versión es un cambio revisable en git. Fijarla aún más (por ejemplo node:24.15-alpine) da builds más repetibles a cambio de actualizar a mano.

5. ¿Un formulario de una SPA sin backend puede servir para atacar el servidor?

Respuesta:

No directamente, porque los datos nunca llegan al servidor: en este proyecto se guardan en el localStorage del propio visitante. Lo peor que puede hacer alguien es ensuciar su propio navegador, y Angular escapa lo que se muestra con interpolación, así que el texto no se ejecuta como HTML. Los riesgos reales pasan a ser otros: usar innerHTML o bypassSecurityTrust* con datos del usuario, guardar datos sensibles en localStorage (cualquier script de la página puede leerlos) y, el día que haya una API, confiar en la validación del cliente. La validación que protege al servidor siempre tiene que estar en el servidor.