Tu primera aplicación con Angular 22: de ng new a una lista de tareas con signals

Empezar con Angular hoy se parece poco a lo que se contaba en los tutoriales de hace unos años. Los módulos (NgModule) han dejado de ser la puerta de entrada, el archivo del componente raíz se llama app.ts, las aplicaciones nuevas nacen sin zone.js y las pruebas unitarias corren sobre Vitest en lugar de Karma. A 4 de noviembre de 2026 la versión estable que publica npm para @angular/cli es la 22.2, y es la que usamos en esta guía.

El recorrido es completo: requisitos, instalación del CLI, qué pregunta y qué genera ng new, cómo se arranca la aplicación desde main.ts, y una lista de tareas con signals, plantillas con control flow integrado y tests. Si ya conoces el lenguaje y quieres profundizar en inyección de dependencias o HttpClient, tienes TypeScript con Angular: decoradores, DI tipado, HttpClient y señales, y para una visión general del framework, Angular: el marco de trabajo de JavaScript para aplicaciones modernas.

Requisitos: qué Node necesitas

Angular solo soporta versiones de Node.js con soporte activo o de mantenimiento (LTS). La tabla de compatibilidad de la documentación oficial (angular.dev) fija para Angular 22 estos mínimos:

Angular

Node.js

TypeScript

RxJS

22.0.x

^22.22.3 || ^24.15.0 || ^26.0.0

>=6.0.0 <6.1.0

^6.5.3 || ^7.4.0

21.x

^20.19.0 || ^22.12.0 || ^24.0.0

>=5.9.0 <6.0.0

^6.5.3 || ^7.4.0

En la práctica, con Node 22 o 24 recientes vas bien. Comprueba la versión instalada antes de seguir:

node --version
npm --version

Si tu sistema trae un Node más antiguo, actualízalo con tu gestor habitual (nvm, fnm, el instalador oficial o el paquete de tu distribución) antes de instalar el CLI; es el fallo más frecuente en una primera instalación, porque el CLI se niega a ejecutarse con una versión no soportada. TypeScript y RxJS no hay que instalarlos aparte: ng new los incluye en el package.json del proyecto con las versiones que le tocan a cada release.

Instalar Angular CLI

El CLI se instala de forma global con npm (en Linux y macOS puede hacer falta sudo según cómo esté configurado tu prefijo de npm, y en Windows PowerShell puede pedirte relajar la política de ejecución de scripts):

npm install -g @angular/cli
ng version

La documentación oficial recoge también pnpm install -g @angular/cli, yarn global add @angular/cli y bun install -g @angular/cli. Si prefieres no instalar nada de forma global, npx @angular/cli new primera-app descarga y ejecuta el CLI solo para crear el proyecto; después, dentro del proyecto, ng se resuelve con la copia local.

Crear el proyecto con ng new

ng new primera-app
cd primera-app

Según lo que no indiques como parámetro, el asistente te irá preguntando por el formato de hojas de estilo (CSS, SCSS, Sass, Less o Tailwind), si quieres renderizado en servidor y generación estática (SSR/SSG) y para qué herramientas de IA generar archivos de configuración (Claude Code, Cursor, Gemini CLI, OpenAI Codex o VS Code). Para una primera aplicación tiene sentido CSS, sin SSR y sin ficheros de IA. Todo eso se puede fijar de antemano, lo que además deja el comando reproducible en scripts:

ng new primera-app --style=css --ssr=false --ai-config=none

Estas son las opciones más útiles del esquema oficial del generador, con su valor por defecto en esta versión:

  • --standalone (activo): componentes independientes, sin NgModule.
  • --zoneless (activo en la aplicación generada): sin zone.js, la detección de cambios se apoya en signals y eventos.
  • --test-runner (vitest; la alternativa es karma).
  • --file-name-style-guide (2025): archivos como app.ts en lugar de app.component.ts. Con 2016 recuperas la convención antigua.
  • --strict (activo): comprobación de tipos más estricta y presupuestos de tamaño más ajustados.
  • --minimal: proyecto sin tests, pensado para experimentos.
  • --skip-git, --skip-install y --package-manager (npm, yarn, pnpm o bun).

Qué hay dentro del proyecto

La parte que importa del árbol generado es esta:

primera-app/
??? angular.json
??? package.json
??? tsconfig.json
??? tsconfig.app.json
??? tsconfig.spec.json
??? public/
?   ??? favicon.ico
??? src/
    ??? index.html
    ??? main.ts
    ??? styles.css
    ??? app/
        ??? app.ts
        ??? app.html
        ??? app.css
        ??? app.spec.ts
        ??? app.config.ts
        ??? app.routes.ts

angular.json describe los objetivos del workspace (build, serve, test), los tsconfig separan la configuración de la aplicación y la de los tests, y public/ contiene los recursos estáticos que se copian tal cual al resultado final. src/index.html es la única página HTML real, y su cuerpo contiene el selector del componente raíz, <app-root></app-root>.

main.ts: el arranque

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));

Sin módulo raíz, bootstrapApplication recibe el componente raíz y un objeto de configuración. Ahí está toda la diferencia con el Angular de antes de los componentes standalone.

app.config.ts: los proveedores globales

import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes)
  ]
};

Aquí se registra lo que antes iba en los imports y providers del módulo raíz: el router, provideHttpClient() cuando lo necesites, animaciones, etc. Si generas el proyecto con un esquema que mantenga zone.js, aparecerá además provideZoneChangeDetection; en una aplicación zoneless no hace falta ningún proveedor extra para la detección de cambios.

app.ts: el componente raíz

import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  imports: [RouterOutlet],
  selector: 'app-root',
  styleUrl: './app.css',
  templateUrl: './app.html',
})
export class App {
  protected readonly title = signal('primera-app');
}

Hay tres detalles que conviene fijarse. La clase se llama App, no AppComponent. El componente declara en imports lo que usa su plantilla, sin módulos de por medio. Y el estado del ejemplo, title, ya es un signal: una función que se lee con paréntesis, title(), y a la que Angular puede seguir la pista para saber qué partes de la vista actualizar.

Signals en tres líneas

Los signals son la base del modelo de reactividad actual y los vas a usar en la mini-aplicación, así que conviene tener el vocabulario claro:

  • signal(valorInicial) crea un valor reactivo. Se lee llamándolo, contador(), y se cambia con .set(valor) o .update(anterior => nuevo).
  • computed(() => ...) deriva un valor de otros signals. Se recalcula de forma perezosa y solo cuando cambia alguna de sus dependencias.
  • effect(() => ...) ejecuta efectos secundarios (guardar en localStorage, registrar en consola) cada vez que cambian los signals que lee.

Arrancar, compilar y probar

ng serve --open

El servidor de desarrollo escucha en http://localhost:4200/, recompila al guardar y recarga el navegador. Con --open (o -o) abre la pestaña por ti.

ng build

Genera la versión de producción optimizada. Para una aplicación sin SSR, los archivos que se publican en cualquier hosting estático quedan en dist/primera-app/browser.

ng test
ng test --no-watch

Ejecuta los tests con Vitest. En un terminal interactivo arranca en modo vigilancia, y con --no-watch hace una sola pasada y termina, que es lo que quieres en integración continua. El proyecto trae ya un app.spec.ts que comprueba que el componente se crea y que el título se pinta en un <h1>.

Mini-app: una lista de tareas con signals

Vamos a sustituir la página de bienvenida por algo útil: una lista de tareas que se puede filtrar y que sobrevive a recargar la página. Empezamos generando el componente:

ng generate component todo-list

Con el estilo de nombres por defecto crea src/app/todo-list/todo-list.ts, .html, .css y .spec.ts, con la clase TodoList y el selector app-todo-list. Esta es la clase completa:

import { Component, computed, effect, signal } from '@angular/core';

interface Tarea {
  id: number;
  titulo: string;
  hecha: boolean;
}

type Filtro = 'todas' | 'pendientes' | 'hechas';

const CLAVE_ALMACEN = 'tareas-angular';

@Component({
  selector: 'app-todo-list',
  templateUrl: './todo-list.html',
  styleUrl: './todo-list.css',
})
export class TodoList {
  protected readonly filtros: Filtro[] = ['todas', 'pendientes', 'hechas'];

  protected readonly tareas = signal<Tarea[]>(this.cargar());
  protected readonly filtro = signal<Filtro>('todas');

  protected readonly visibles = computed(() => {
    const filtro = this.filtro();
    return this.tareas().filter((t) =>
      filtro === 'todas' || (filtro === 'hechas' ? t.hecha : !t.hecha)
    );
  });

  protected readonly pendientes = computed(
    () => this.tareas().filter((t) => !t.hecha).length
  );

  constructor() {
    effect(() => {
      localStorage.setItem(CLAVE_ALMACEN, JSON.stringify(this.tareas()));
    });
  }

  protected anadir(entrada: HTMLInputElement): void {
    const titulo = entrada.value.trim();
    if (!titulo) {
      return;
    }
    const id = Math.max(0, ...this.tareas().map((t) => t.id)) + 1;
    this.tareas.update((lista) => [...lista, { id, titulo, hecha: false }]);
    entrada.value = '';
    entrada.focus();
  }

  protected alternar(id: number): void {
    this.tareas.update((lista) =>
      lista.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t))
    );
  }

  protected borrar(id: number): void {
    this.tareas.update((lista) => lista.filter((t) => t.id !== id));
  }

  protected limpiarHechas(): void {
    this.tareas.update((lista) => lista.filter((t) => !t.hecha));
  }

  private cargar(): Tarea[] {
    try {
      const guardado = localStorage.getItem(CLAVE_ALMACEN);
      return guardado ? (JSON.parse(guardado) as Tarea[]) : [];
    } catch {
      return [];
    }
  }
}

Hay cuatro decisiones de diseño que merece la pena señalar. Primera: el estado vive en un solo signal, tareas, y se modifica siempre con update devolviendo un array nuevo, nunca mutando el anterior, porque los signals detectan el cambio comparando referencias. Segunda: lo que se puede derivar (visibles, pendientes) es un computed, no estado duplicado que haya que mantener sincronizado a mano. Tercera: el effect del constructor persiste la lista en localStorage cada vez que cambia, y cargar() la recupera al instanciar el componente, con un try/catch por si el contenido guardado está corrupto o el almacenamiento no está disponible. Cuarta: los miembros que solo usa la plantilla son protected, y los que son internos, private, de modo que la API pública del componente queda vacía.

Ahora la plantilla, todo-list.html:

<section class="tareas">
  <h2>Mis tareas</h2>

  <div class="nueva">
    <input #nueva type="text" placeholder="¿Qué hay que hacer?" (keydown.enter)="anadir(nueva)">
    <button type="button" class="anadir" (click)="anadir(nueva)">Añadir</button>
  </div>

  <div class="filtros">
    @for (f of filtros; track f) {
      <button type="button" [class.activo]="filtro() === f" (click)="filtro.set(f)">{{ f }}</button>
    }
  </div>

  <ul>
    @for (t of visibles(); track t.id) {
      <li [class.hecha]="t.hecha">
        <label>
          <input type="checkbox" [checked]="t.hecha" (change)="alternar(t.id)">
          {{ t.titulo }}
        </label>
        <button type="button" (click)="borrar(t.id)" [attr.aria-label]="'Borrar ' + t.titulo">?</button>
      </li>
    } @empty {
      <li class="vacio">No hay tareas en esta vista.</li>
    }
  </ul>

  <footer>
    {{ pendientes() }} {{ pendientes() === 1 ? 'pendiente' : 'pendientes' }}
    @if (pendientes() < tareas().length) {
      <button type="button" (click)="limpiarHechas()">Borrar completadas</button>
    }
  </footer>
</section>

La sintaxis de bloques (@for, @empty, @if) forma parte del propio compilador de plantillas y no necesita importar NgFor ni NgIf. El track de @for es obligatorio: le dice a Angular cómo identificar cada elemento para reutilizar el DOM existente al cambiar la lista. Aquí usamos t.id, que es único y estable. La variable de plantilla #nueva da acceso directo al elemento <input>, con su tipo, sin necesidad de FormsModule ni de enlazar el valor en cada pulsación.

Un poco de estilo en todo-list.css (los estilos de un componente quedan encapsulados en él):

.tareas { max-width: 28rem; margin: 2rem auto; font-family: system-ui, sans-serif; }
.nueva { display: flex; gap: .5rem; }
.nueva input { flex: 1; padding: .5rem; }
.filtros { display: flex; gap: .5rem; margin: 1rem 0; }
.filtros .activo { font-weight: 700; text-decoration: underline; }
ul { list-style: none; padding: 0; }
li { display: flex; justify-content: space-between; align-items: center; padding: .4rem 0; border-bottom: 1px solid #ddd; }
li.hecha label { text-decoration: line-through; opacity: .6; }
.vacio { justify-content: center; opacity: .6; }
footer { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; }

Por último, el componente raíz. Reemplaza el contenido de app.html por <app-todo-list /> e importa TodoList en app.ts. Como la plantilla ya no usa <router-outlet>, quitamos RouterOutlet de los imports (Angular avisa de las importaciones que no se usan):

import { Component } from '@angular/core';
import { TodoList } from './todo-list/todo-list';

@Component({
  imports: [TodoList],
  selector: 'app-root',
  styleUrl: './app.css',
  templateUrl: './app.html',
})
export class App {}

Al ser el componente raíz tan pequeño, el spec que traía ng new ya no encontrará el <h1> con «Hello, primera-app». Bórralo o sustitúyelo por el test del apartado siguiente. Con ng serve en marcha, añade un par de tareas, márcalas, filtra y recarga la página: la lista se mantiene.

Tests del componente con Vitest

Los tests de Angular con Vitest usan las funciones globales describe, it y expect, igual que el spec generado, y TestBed para montar el componente. Como la aplicación es zoneless, tras cada interacción se espera a que el componente se estabilice con await fixture.whenStable(). Estos dos tests, en todo-list.spec.ts, cubren el flujo principal sin acceder a miembros protected, interactuando solo a través del DOM como lo haría un usuario:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TodoList } from './todo-list';

describe('TodoList', () => {
  let fixture: ComponentFixture<TodoList>;
  let el: HTMLElement;

  beforeEach(async () => {
    localStorage.clear();
    await TestBed.configureTestingModule({ imports: [TodoList] }).compileComponents();
    fixture = TestBed.createComponent(TodoList);
    el = fixture.nativeElement as HTMLElement;
    await fixture.whenStable();
  });

  async function anadir(texto: string): Promise<void> {
    el.querySelector('input')!.value = texto;
    el.querySelector<HTMLButtonElement>('button.anadir')!.click();
    await fixture.whenStable();
  }

  it('añade una tarea y actualiza el contador', async () => {
    await anadir('Aprender signals');

    expect(el.querySelectorAll('li:not(.vacio)').length).toBe(1);
    expect(el.querySelector('footer')?.textContent).toContain('1 pendiente');
  });

  it('marca una tarea como hecha', async () => {
    await anadir('Escribir tests');
    el.querySelector<HTMLInputElement>('li input[type="checkbox"]')!.click();
    await fixture.whenStable();

    expect(el.querySelector('li')!.classList.contains('hecha')).toBe(true);
    expect(el.querySelector('footer')?.textContent).toContain('0 pendientes');
  });
});

Lánzalos con ng test --no-watch. El localStorage.clear() del beforeEach es importante: como el componente persiste su estado, sin él un test heredaría las tareas del anterior.

Siguientes pasos

Con esto tienes el ciclo completo: crear, ejecutar, compilar y probar. Para convertir la lista en una aplicación de verdad, los pasos naturales son separar el estado en un servicio inyectable (la lista de tareas como signal dentro de un @Injectable({ providedIn: 'root' })), añadir rutas en app.routes.ts para una vista de detalle, cargar datos de una API con provideHttpClient() y HttpClient, y sustituir el campo de texto suelto por formularios reactivos cuando haya validación. Si necesitas posicionamiento en buscadores o tiempos de primera carga más bajos, ng new puede generar el proyecto con SSR y prerenderizado desde el principio; conviene decidirlo al crearlo, porque cambia la estructura de salida (aparece una carpeta server junto a browser).

Todo lo que aparece en esta guía puede contrastarse con la documentación oficial en angular.dev, donde se mantienen al día la tabla de versiones, la guía de instalación local y la referencia del CLI.

Imagen: Pexels / Nemuel Sereti

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP