Tag: Angular

  • Cuándo usar RxJS o Signals en Angular: el criterio real

    Cuándo usar RxJS o Signals en Angular: el criterio real

    El mes pasado, en un code review, me encontré esto:

    // user.service.ts
    private readonly userSubject = new BehaviorSubject<User | null>(null);
    readonly user$ = this.userSubject.asObservable();
    
    setUser(user: User) {
      this.userSubject.next(user);
    }
    

    Nadie combinaba ese observable con nada. Nadie lo cancelaba. Solo se consumía con async en dos plantillas. Es un signal con tres pasos de más.

    Dos archivos más abajo estaba el problema espejo:

    readonly productos = signal<Product[]>([]);
    readonly cargando = signal(false);
    readonly error = signal<string | null>(null);
    private peticionEnCurso = 0; // para descartar respuestas viejas
    

    Eso ya no es estado. Es un observable reimplementado a mano, y mal.

    Le pregunté al dev qué criterio había usado. Me dio el mismo que da todo el mundo cuando le preguntas cuándo usar RxJS o Signals en Angular: "Signals para estado, RxJS para async".

    La regla es correcta. Yo también la he escrito. Y no le sirvió de nada, porque los dos fragmentos que acabas de ver la cumplen.

    Cuándo usar RxJS o Signals en Angular: la regla que no decide nada

    "Estado" y "async" no describen tu código. Describen categorías. Y tu código real vive justo en la frontera entre las dos.

    Un autocompletado con debounceTime que además guarda el producto elegido: ¿eso es estado o es async? Las dos cosas. Un WebSocket de precios que solo se pinta en pantalla: es async por definición, y sin embargo la UI solo quiere el último valor. Una cancelación de peticiones que ayer era switchMap y hoy podría ser un resource.

    En todos esos casos la regla te deja exactamente donde estabas: eligiendo por costumbre.

    El criterio que sí decide: ¿necesitas el valor o la secuencia?

    Signals te dan el valor actual. Qué vale esto ahora, en este instante, cuando alguien lo lee.

    RxJS te da los eventos en el tiempo. Qué pasó, en qué orden, cuántas veces, y qué hacer si llega otro antes de que termine el anterior.

    Esa es toda la decisión:

    • Si el cuándo y el en qué orden forman parte de tu lógica, es RxJS.
    • Si solo importa qué vale ahora, es un signal.

    Aplícalo a los dos fragmentos del principio y se resuelven solos. El BehaviorSubject del usuario nunca pregunta cuántas veces cambió ni en qué orden: quiere el valor actual. Es un signal. Y el signal() rodeado de cargando, error y peticionEnCurso está preguntando exactamente eso —cuál llegó primero, cuál descarto— con las manos. Es un flujo.

    La pista más fiable es esta: en cuanto necesitas una variable auxiliar para saber qué pasó antes, has salido del territorio de los signals.

    Tabla de decisión: RxJS o Signals, caso por caso

    Necesitas… Elige Por qué
    El valor actual para pintarlo signal Solo importa qué vale ahora
    Derivar un valor de otros computed Sin suscripciones ni sincronización manual
    Que el orden de llegada cambie el resultado RxJS El tiempo es parte de la lógica
    Esperar a que el usuario deje de escribir RxJS (debounceTime) Los signals no tienen noción de tiempo
    Reaccionar a cada evento, uno a uno RxJS El grafo de signals colapsa valores intermedios
    Descartar la respuesta anterior al llegar otra switchMap o un resource Cancelación a mano o incluida
    Guardar lo que el usuario eligió signal Es estado, no un evento
    Un push del servidor que solo se pinta toSignal() del stream La secuencia se procesa fuera; el valor entra al grafo

    Esta tabla decide. No ejecuta. Cuando ya sepas qué mover, el cómo —los patrones de refactorización, uno a uno— está en la guía de migración de RxJS a Signals.

    Frontera 1: el autocompletado que además guarda la selección

    El caso que rompe la regla. Hay tiempo (debounce, cancelación) y hay estado (el término, la selección).

    No elijas. Parte el componente por la mitad:

    readonly term = signal('');                        // valor
    readonly selected = signal<Product | null>(null);  // valor
    
    private readonly results$ = toObservable(this.term).pipe( // secuencia
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((t) => this.http.get<Product[]>(`/api/products?q=${encodeURIComponent(t)}`)),
    );
    
    readonly results = toSignal(this.results$, { initialValue: [] }); // vuelve a valor
    
    choose(p: Product) {
      this.selected.set(p);
      this.term.set(p.name);
    }
    

    Estado a los lados, secuencia en el medio. El pipeline no guarda nada y los signals no saben nada del tiempo.

    Este reparto —qué vive en el grafo y qué vive en el pipeline— es la base de la arquitectura de señales que trabajo a fondo en el curso de Angular Moderno. Y cómo se ordena a escala de aplicación entera —servicios, stores, componentes— lo desarrollé en la arquitectura moderna de Angular con Signals.

    Frontera 2: el WebSocket que alimenta la UI

    Aquí todo el mundo asume RxJS y se queda ahí. La mitad de la respuesta es correcta.

    La conexión y el filtrado son secuencia pura: RxJS. Pero lo que la plantilla consume es un valor.

    private readonly ticks$ = webSocket<Tick>('wss://api.example.com/ticks').pipe(
      filter((t) => t.symbol === 'BTC'),
    );
    
    readonly lastTick = toSignal(this.ticks$, { initialValue: null });
    

    toSignal() se desuscribe solo cuando se destruye el componente o el servicio que lo crea, salvo que le pases manualCleanup. No hay takeUntil, ni async repetido en la plantilla, ni ngOnDestroy.

    Y si tu lógica necesita cada tick —contarlos, agruparlos por ventana, acumularlos— eso se queda en el pipeline. No lo subas al signal.

    Frontera 3: cancelar peticiones, switchMap o un resource

    Este es el que más ha cambiado.

    switchMap cancela la petición anterior cuando llega un valor nuevo. Sigue siendo la respuesta correcta si en el mismo flujo hay debounce, reintentos con backoff o una combinación de varias fuentes.

    Pero si lo único que haces es "cuando cambia este parámetro, vuelve a pedir y descarta lo anterior", ya no necesitas escribir el flujo. httpResource es un envoltorio sobre HttpClient que te da el estado de la petición y la respuesta como señales, pasando por los interceptors. Y rxResource acepta una propiedad stream con una función que devuelve un Observable, para cuando la fuente ya la tienes en RxJS:

    readonly detail = rxResource({
      params: () => this.selectedId(),
      stream: ({ params }) => this.http.get<Product>(`/api/products/${params}`),
    });
    

    Un detalle de la documentación que cuesta caro descubrir en producción: ese Observable debe emitir un valor o un error antes de completar. Si le encadenas un catchError(() => EMPTY) —o un filter que a veces no deja pasar nada— y el Observable completa sin emitir, Angular lanza NG0991, Resource completed before producing a value. No se queda esperando: revienta.

    Frontera 4: dónde va el efecto secundario, effect() o tap()

    La diferencia no es de estilo. Es de garantías.

    tap() se ejecuta una vez por evento, en un punto conocido del orden, y ve el evento.

    effect() se ejecuta cuando el valor acaba siendo algo. No te garantiza que veas todos los intermedios: el grafo de señales agrupa actualizaciones y ejecuta el efecto sobre el resultado ya estabilizado. Si alguna vez te has preguntado por qué un computed no se recalcula cuando esperabas, la explicación está en cómo funciona el grafo reactivo de Angular por dentro.

    La regla práctica:

    • Analítica de "el usuario pulsó el botón" → tap(). Cada pulsación cuenta.
    • "Cuando el carrito quede vacío, oculta el panel" → effect(). Da igual cómo llegó a estar vacío.

    Y si el efecto es calcular otro valor, no es un efecto. Es un computed. Escribir signals dentro de un effect es la forma más rápida de construir un grafo que nadie entiende y que ningún test cubre.

    La frontera exacta de toSignal()

    toSignal() es el punto donde una secuencia se convierte en valor. Vive en @angular/core/rxjs-interop y necesita un contexto de inyección, o que le pases un Injector.

    Tres cosas que la documentación dice y casi nadie lee.

    Se suscribe inmediatamente. La doc lo avisa: "Like the async pipe, toSignal subscribes to the Observable immediately, which may trigger side effects". Si tu observable dispara una petición al suscribirse, esa petición sale ya.

    No lo llames dos veces. "You should avoid calling it repeatedly for the same Observable, and instead reuse the signal it returns". Cada llamada es una suscripción nueva. Dos toSignal() sobre el mismo stream son dos conexiones.

    Los errores se lanzan al leer. No al emitir: al leer el signal. Y si el observable completa, el signal se queda con el último valor emitido, no se vacía.

    Traducido: toSignal() es una frontera de salida, y se cruza una sola vez, lo más cerca posible de la plantilla.

    Cuándo toObservable() te avisa de que vas al revés

    toObservable() es legítimo, y lo has visto arriba: tienes un signal de input y necesitas operadores de tiempo. Estado que se convierte en secuencia.

    El problema es el otro uso, el de volver a RxJS por inercia. Y tiene una trampa medible.

    toObservable() está implementado con un effect y un ReplaySubject. Eso significa que agrupa varias actualizaciones del signal en una sola emisión. Si actualizas el signal tres veces seguidas, no vas a recibir tres valores.

    Así que si tu pipeline necesita ver cada cambio, toObservable() no te lo va a dar. Y si no necesita verlos, probablemente no necesitabas el pipeline.

    Dos señales de que has girado en dirección equivocada: usarlo para combinar dos signals —eso es un computed— o usarlo para ejecutar algo cuando cambia un valor, que es un effect.

    toSignal() debería aparecer varias veces en tu código. toObservable(), muy pocas, y cada una con un operador de tiempo detrás que lo justifique.

    Qué hacer hoy en tu proyecto Angular

    Abre tu proyecto y busca signals con una variable auxiliar al lado: un flag cargando, un contador de peticiones, un ultimoId. Cada una de esas variables es la secuencia que el signal no sabe representar. Ahí tienes un flujo pidiendo volver a RxJS.

    Luego busca lo contrario, que cuesta menos: cada BehaviorSubject del que solo se lee el último valor. Si nadie depende del orden en que llegaron —y en un .asObservable() consumido con async casi nunca se depende—, tienes un signal esperando a salir.

    Los dos del principio de este post salen así: el BehaviorSubject del usuario son tres líneas —subject privado, observable público y setter— que se quedan en una, signal<User | null>(null). Y los cuatro campos del segundo caso —productos, cargando, error y peticionEnCurso— se quedan en un resource. Puedes contarlos tú en los snippets de arriba.

    Diez minutos. Y decides con el criterio, no con la moda.

    Y si quieres discutir estas decisiones sobre proyectos reales, y no sobre listas de tareas, es justo lo que hacemos en Dominicode Labs.

    Preguntas frecuentes

    ¿Cuándo usar RxJS o Signals en Angular?

    Pregúntate si necesitas el valor o la secuencia. Los signals te dan el valor actual: qué vale algo ahora mismo. RxJS te da los eventos en el tiempo: qué pasó, en qué orden y qué hacer si llega otro antes de terminar el anterior. Si el cuándo y el orden forman parte de tu lógica, es RxJS. Si solo importa el valor actual, es un signal. La pista más fiable es que necesites una variable auxiliar para recordar qué pasó antes: ahí ya has salido del territorio de los signals.

    ¿Sigue teniendo sentido RxJS ahora que existen los signals?

    Sí, y no por compatibilidad hacia atrás. Los signals no tienen ninguna noción de tiempo: no saben esperar, ni descartar, ni contar emisiones, ni reintentar con backoff. Nada de eso lo cubre el grafo reactivo. Mientras tu lógica dependa de la secuencia —debounce, WebSockets, coordinación de varias fuentes asíncronas, cancelación combinada con otros operadores— RxJS es la herramienta correcta y no hay sustituto.

    ¿Debo reemplazar todos mis BehaviorSubject por signals?

    Todos no. Reemplaza los que solo mantienen el último valor y se consumen con la pipe async o con un subscribe que hace un set. Esos son signals con pasos de más. Deja los que participan en un flujo real: los que se combinan con otros streams, los que alimentan un pipeline con operadores de tiempo o los que representan eventos en los que el orden importa. El criterio no es el tipo, es qué haces con él.

    ¿Cuál es la diferencia entre effect() y tap() para efectos secundarios?

    tap() se ejecuta una vez por evento, en un punto conocido del flujo, y recibe el evento. effect() se ejecuta cuando el valor acaba estabilizándose, y no garantiza que veas los estados intermedios porque el grafo agrupa actualizaciones. Para "el usuario pulsó tres veces" necesitas tap(). Para "cuando esto quede vacío, oculta el panel" necesitas effect(). Y si lo que quieres es calcular otro valor, el effect sobra: eso lo resuelve un computed.

    ¿Es mala señal usar toObservable() en mi código?

    Depende de en qué dirección vayas. Si conviertes un signal de estado en secuencia para aplicarle operadores de tiempo, es exactamente para lo que existe. Si lo usas para combinar dos signals o para reaccionar a un cambio, te estás alejando: eso son computed y effect. Además tiene una consecuencia práctica que conviene conocer: agrupa varias actualizaciones del signal en una sola emisión, así que no verás todos los valores intermedios.


    Comportamiento verificado contra la documentación de Angular 22 en septiembre de 2026.

    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • httpResource vs TanStack Query: cuál usar en Angular 22

    httpResource vs TanStack Query: cuál usar en Angular 22

    Un dev me escribió hace tres semanas con una captura de su package.json. Proyecto Angular 22 recién creado, dos días de vida, y ya tenía @tanstack/angular-query-experimental dentro.

    Le pregunté por qué. Respuesta: "en React siempre lo uso".

    Ese es el 80% de las discusiones de httpResource vs TanStack Query que veo por ahí: no son decisiones, son inercias. El resto viene del extremo contrario. Alguien leyó en un hilo que "httpResource no cachea", cerró la pestaña y descartó la API estándar de Angular sin llegar a preguntarse qué caché necesitaba de verdad su aplicación.

    Las dos posturas fallan por el mismo motivo: comparan dos cosas que no juegan en la misma liga.

    Este post no es un tutorial. Si buscas el cómo, ya publiqué la guía de la Resource API en Angular 22 y la de httpResource con señales. Aquí vamos a lo otro: qué eliges, por qué, y qué te va a costar.

    httpResource vs TanStack Query: la respuesta corta

    Usa httpResource por defecto en Angular 22. Es la API estándar, es estable desde la v22.0, no añade dependencias y hereda todo el ecosistema de HttpClient. Cambia a TanStack Query solo cuando necesites una caché de servidor compartida entre componentes con invalidación por clave, mutaciones optimistas o scroll infinito, y estés dispuesto a asumir que su adaptador de Angular se llama literalmente @tanstack/angular-query-experimental.

    Esa es la decisión. El resto del post explica por qué, con la letra pequeña que casi nadie te cuenta.

    La diferencia real: petición reactiva vs caché de estado de servidor

    httpResource es una primitiva de petición reactiva. Le das una función que construye una URL a partir de señales, y la petición se vuelve a lanzar sola cuando esas señales cambian. Vive dentro del grafo reactivo de Angular como un nodo más. Su unidad de trabajo es una petición atada a un estado.

    TanStack Query es otra cosa. Es una capa de caché de estado de servidor. Su unidad de trabajo no es la petición: es la clave. Cuando escribes queryKey: ['products', filtro], estás diciendo "estos datos existen en la aplicación bajo este identificador". A partir de ahí llegan la deduplicación de peticiones en vuelo, la invalidación cruzada, el stale-while-revalidate y las mutaciones optimistas. Todo eso son consecuencias de tener una clave y un almacén global, no de saber hacer un GET.

    Por eso la pregunta "¿cuál es mejor?" lleva casi siempre a la respuesta equivocada. Es como comparar fetch con Redis. Uno trae datos; el otro decide quién los tiene, cuándo caducan y a quién hay que avisar.

    La pregunta correcta es esta: ¿tu aplicación tiene un problema de caché de servidor, o solo tiene peticiones que dependen de un estado?

    Cinco pantallas CRUD con filtros y un detalle no tienen problema de caché. Un dashboard donde ocho componentes distintos leen el mismo listado, donde una mutación en un modal tiene que refrescar tres widgets y donde el usuario espera ver el cambio antes de que responda el servidor, sí lo tiene.

    El mismo caso resuelto con httpResource

    Lista de productos con filtro reactivo. Esta es la versión con la API estándar.

    import { Component, signal } from '@angular/core';
    import { httpResource } from '@angular/common/http';
    
    type Product = { id: string; name: string; price: number };
    
    @Component({
      selector: 'app-products',
      template: `
        <input [value]="query()" (input)="query.set($any($event.target).value)" />
    
        @if (products.isLoading()) {
          <p>Cargando…</p>
        } @else if (products.error()) {
          <p>No se pudo cargar el catálogo</p>
        } @else {
          <ul>
            @for (product of products.value(); track product.id) {
              <li>{{ product.name }} — {{ product.price }} €</li>
            }
          </ul>
        }
      `,
    })
    export class ProductsComponent {
      readonly query = signal('');
    
      readonly products = httpResource<Product[]>(
        () => `/api/products?q=${encodeURIComponent(this.query())}`,
        { defaultValue: [] },
      );
    }
    

    Cero dependencias. Cero providers. El HttpResourceRef te expone value, status, error, headers, statusCode, progress e isLoading como señales, más los métodos hasValue() y reload().

    Hay un detalle que se pasa por alto: la documentación oficial aclara que si hay una petición pendiente cuando cambia la dependencia, el resource cancela la anterior antes de lanzar la nueva. El caso del input que escribe rápido está resuelto de fábrica.

    Si quieres validar la respuesta, tienes la opción parse con Zod o Valibot. Y como usa HttpClient por debajo, tus interceptors de auth, de reintentos y de logging siguen aplicando sin tocar nada. Este patrón, con la arquitectura de señales completa alrededor, es lo que trabajo a fondo en el curso de Angular Moderno.

    El mismo caso resuelto con TanStack Query

    Mismo componente, otra filosofía. Primero el provider en el arranque.

    import { provideHttpClient } from '@angular/common/http';
    import { provideTanStackQuery, QueryClient } from '@tanstack/angular-query-experimental';
    
    bootstrapApplication(AppComponent, {
      providers: [provideHttpClient(), provideTanStackQuery(new QueryClient())],
    });
    

    Y el componente:

    import { Component, inject, signal } from '@angular/core';
    import { HttpClient } from '@angular/common/http';
    import { lastValueFrom } from 'rxjs';
    import {
      injectQuery,
      injectMutation,
      QueryClient,
    } from '@tanstack/angular-query-experimental';
    
    type Product = { id: string; name: string; price: number };
    type NewProduct = Omit<Product, 'id'>;
    
    @Component({
      selector: 'app-products',
      template: `
        <input [value]="query()" (input)="query.set($any($event.target).value)" />
    
        @if (products.isPending()) {
          <p>Cargando…</p>
        } @else if (products.isError()) {
          <p>No se pudo cargar el catálogo</p>
        } @else {
          <ul>
            @for (product of products.data() ?? []; track product.id) {
              <li>{{ product.name }} — {{ product.price }} €</li>
            }
          </ul>
        }
      `,
    })
    export class ProductsComponent {
      private readonly http = inject(HttpClient);
      private readonly queryClient = inject(QueryClient);
    
      readonly query = signal('');
    
      readonly products = injectQuery(() => ({
        queryKey: ['products', this.query()],
        queryFn: () =>
          lastValueFrom(
            this.http.get<Product[]>(`/api/products?q=${encodeURIComponent(this.query())}`),
          ),
      }));
    
      readonly createProduct = injectMutation(() => ({
        mutationFn: (product: NewProduct) =>
          lastValueFrom(this.http.post<Product>('/api/products', product)),
        onSuccess: () => {
          this.queryClient.invalidateQueries({ queryKey: ['products'] });
        },
      }));
    }
    

    Fíjate en lo que aparece y en lo que desaparece.

    Aparece el queryKey: ese array es la identidad de los datos en toda la aplicación. Si otros seis componentes montan la misma clave, hay una sola petición y una sola copia en memoria. Y aparece invalidateQueries, la pieza que httpResource no tiene: un componente puede invalidar datos que él nunca pidió.

    Desaparece la simplicidad. Tienes un provider global, un queryFn que devuelve promesas (de ahí el lastValueFrom para puentear el observable de HttpClient) y un vocabulario nuevo que todo el equipo tiene que aprender.

    Tabla comparativa: httpResource vs TanStack Query en Angular 22

    Solo celdas verificadas contra la documentación y los tipos publicados de ambos proyectos, a septiembre de 2026: Angular 22.1.4 y @tanstack/angular-query-experimental 5.102.8.

    Criterio httpResource (Angular 22) TanStack Query (adaptador Angular)
    Estabilidad Estable desde v22.0 Experimental: breaking changes en releases minor y patch
    Dependencias Ninguna, viene en @angular/common/http @tanstack/angular-query-experimental, que arrastra @tanstack/query-core
    Caché compartida entre componentes No Sí, por queryKey
    Deduplicación de peticiones en vuelo No entre instancias; cancela su propia petición anterior Sí, por clave
    Invalidación por clave No queryClient.invalidateQueries({ queryKey })
    Stale-while-revalidate Conserva el valor anterior mientras recarga, pero no hay política de caducidad ni refetch en segundo plano Sí, con staleTime y refetch en segundo plano
    Reintentos automáticos No incluidos (las opciones son parse, defaultValue, injector, equal y debugName) Sí, tres reintentos por defecto con backoff exponencial
    Mutaciones y updates optimistas Fuera de alcance por diseño injectMutation con invalidación en onSuccess
    Paginación infinita A mano injectInfiniteQuery
    Devtools de datos Sin panel de caché propio; los nodos aparecen en el signal graph de Angular DevTools vía debugName withDevtools() desde el subpath /devtools
    Interceptors de HttpClient Siempre, usa HttpClient por debajo Solo si el queryFn usa HttpClient
    Testing HttpTestingController estándar provideTanStackQuery en TestBed, QueryClient nuevo por spec, retry: false, y requiere Angular ≥ 19
    SSR / hydration Entra en el transfer cache de provideClientHydration Sin guía de SSR propia para Angular; expone injectIsRestoring
    Versión mínima de Angular 22 para la versión estable 16 (19 para la integración de testing)

    Dónde httpResource se queda corto de verdad

    No voy a defender la API estándar más allá de lo que aguanta. Hay cuatro escenarios en los que httpResource te deja escribiendo infraestructura a mano.

    Caché compartida entre componentes. Si el header, la sidebar y la tabla montan tres httpResource contra /api/user, son tres peticiones. No hay almacén común. Puedes subir el resource a un servicio con providedIn: 'root' y compartirlo, claro, pero eso ya es tu caché artesanal, con tus reglas de caducidad escritas por ti y mantenidas por ti.

    Invalidación cruzada tras una mutación. Guardas un producto en un modal y necesitas refrescar el listado, el contador del carrito y el widget de novedades. Con httpResource toca llamar a reload() en cada uno, lo que obliga al modal a conocer a esos tres. Es acoplamiento, y escala mal.

    Paginación infinita. Acumular páginas, saber si hay siguiente, mantener el scroll. injectInfiniteQuery te lo da resuelto. A mano son varias tardes y unos cuantos bugs sutiles.

    Actualizaciones optimistas con rollback. Pintar el cambio antes de que responda el servidor y deshacerlo limpio si falla es un problema de gestión de estado, no de HTTP.

    Y añado una limitación que no es un defecto sino una decisión de diseño: la documentación de Angular es explícita en que hay que evitar httpResource para mutaciones tipo POST o PUT, y usar directamente HttpClient. httpResource lee; no escribe.

    Dónde TanStack Query te sale caro

    Empecemos por lo que está escrito en su propia web, palabra por palabra:

    "This library is currently in an experimental stage. This means that breaking changes will happen in minor AND patch releases."

    Léelo otra vez, porque no es la típica etiqueta beta de adorno. Es el equipo de TanStack diciéndote que un patch puede romperte la capa de datos. Y no es un aviso antiguo: sigue ahí, en la documentación del adaptador de Angular, con el paquete en la versión 5.102.8. El "experimental" está en el nombre del paquete que vas a escribir en tu package.json.

    En una prueba de concepto da igual. En una aplicación de empresa que va a vivir cinco años y que mantendrá otro equipo, eso significa fijar la versión, leerte cada changelog y aceptar que una parte crítica de tu arquitectura evoluciona a un ritmo que tú no controlas.

    El segundo coste es más sutil: te sales del ecosistema de HttpClient. Los interceptors de Angular solo se aplican si tu queryFn usa HttpClient. En cuanto alguien del equipo escribe un queryFn con fetch porque le resulta más cómodo, esa petición se salta el interceptor de auth, el de reintentos y el de trazas. Y no falla en desarrollo: falla el día que caduca un token en producción.

    Ese mismo salto te afecta al testing. httpResource se testea con HttpTestingController, igual que cualquier otra llamada de tu aplicación. Con TanStack Query montas provideTanStackQuery en el TestBed, creas un QueryClient limpio en cada spec, desactivas los reintentos para que los fallos no tarden tres backoffs y esperas a whenStable(). Se puede, está documentado, pero es una capa más que mantener. Si quieres el enfoque completo de testing en Angular moderno, lo tienes en el curso de Testing en Angular con Jest y Testing Library.

    Y el tercer coste es el SSR. httpResource usa HttpClient, así que se beneficia del transfer cache de la hidratación sin que hagas nada. El adaptador de Angular de TanStack Query no publica hoy una guía de SSR equivalente a la de React.

    Veredicto: el árbol de decisión

    Me mojo.

    Por defecto, httpResource. En un proyecto Angular 22 nuevo, empieza con la API estándar. Es estable, no amplía tu superficie de dependencias, integra con interceptors, testing y SSR, y cubre el 80% de los casos reales: listados, detalles, filtros, buscadores. Si hoy dudas, esta es tu respuesta.

    TanStack Query cuando se cumplen las tres condiciones a la vez:

    1. Varios componentes independientes consumen los mismos datos de servidor y quieres una única fuente en memoria.
    2. Necesitas invalidación cruzada tras mutaciones, scroll infinito o updates optimistas, y ya has calculado lo que cuesta escribir todo eso a mano.
    3. Aceptas el riesgo de breaking changes en versiones patch y vas a fijar la versión exacta.

    Si falla una sola de las tres, no lo metas.

    Y la opción que casi nadie considera: los dos. No son excluyentes. Puedes servir el grueso de tus pantallas con httpResource y reservar TanStack Query para el módulo de dashboard donde de verdad existe un problema de caché compartida. Aísla la decisión en una zona del código en lugar de casarte con ella en toda la aplicación.

    Lo que no vale es lo que hacía el dev del package.json: instalarlo el primer día porque en React lo usabas. Angular 22 no es React. El grafo de señales que llegó con la v22 ya te da reactividad de primera clase, y esa es justo la pieza que TanStack Query tuvo que inventar en el mundo de los hooks.

    Qué hacer hoy

    Abre tu proyecto y cuenta cuántos componentes distintos piden el mismo endpoint. Solo eso.

    Si el número es cero o uno, no tienes un problema de caché de servidor: tienes peticiones reactivas, y httpResource te sobra para resolverlas. Si el número es tres o más, y además hay mutaciones que deben refrescar varias vistas a la vez, ahí sí te has ganado el derecho a meter una dependencia externa en la capa de datos.

    Ese conteo tarda diez minutos y vale más que cualquier hilo de discusión en X.

    En Dominicode Labs trabajamos este tipo de decisiones de arquitectura sobre proyectos reales, no sobre ejemplos de listas de tareas. Y si prefieres verlo en vídeo, en el canal de YouTube publico cada semana contenido de Angular moderno e IA aplicada al desarrollo.

    Preguntas frecuentes

    ¿Qué es mejor en Angular 22, httpResource o TanStack Query?

    httpResource es la mejor opción por defecto en Angular 22: es la API estándar, es estable desde la versión 22.0, no añade dependencias y hereda los interceptors, el testing y el transfer cache de HttpClient. TanStack Query solo compensa cuando varios componentes independientes consumen los mismos datos y necesitas invalidación por clave, mutaciones optimistas o scroll infinito. No resuelven el mismo problema: httpResource es una primitiva de petición reactiva y TanStack Query es una capa de caché de estado de servidor.

    ¿httpResource cachea las peticiones?

    No, y ese malentendido origina la mitad de las discusiones sobre el tema. httpResource no mantiene un almacén de datos por clave: lanza la petición cuando cambian las señales de las que depende y expone el resultado como señal. Si necesitas que varios componentes compartan la misma copia en memoria, tienes que construir esa caché tú, por ejemplo elevando el resource a un servicio raíz, o usar una librería que ya la traiga resuelta.

    ¿Puedo usar httpResource y TanStack Query en la misma aplicación?

    Sí, y en muchos proyectos es la decisión más sensata. Nada impide resolver el grueso de las pantallas con la API estándar de Angular y reservar TanStack Query para el módulo concreto que tiene un problema real de caché compartida e invalidación cruzada. Así limitas la superficie de la dependencia externa a una zona del código, en lugar de extenderla por toda la base.

    ¿Es seguro usar el adaptador de Angular de TanStack Query en producción?

    Sí, siempre que fijes la versión exacta, revises el changelog antes de cada actualización y tengas tests que cubran la capa de datos. El riesgo está declarado en su propia documentación: habrá cambios que rompan en versiones minor y también en versiones patch. Si el proyecto lo va a mantener otro equipo dentro de dos años, piénsalo dos veces.

    ¿Los interceptors de HttpClient funcionan con TanStack Query?

    Solo si la función de la query usa HttpClient por debajo. Si alguien la escribe con fetch o con axios porque le resulta más cómodo, esa petición se salta los interceptors de autenticación, reintentos y trazas de Angular, y el problema aparecerá en producción y no en desarrollo. Si adoptas la librería, conviene dejar por escrito en el equipo que toda petición pasa por HttpClient.

    ¿Sirve httpResource para POST y PUT?

    No, y no es una limitación sino una decisión de diseño. La documentación de Angular recomienda de forma explícita evitar httpResource para mutaciones y usar directamente las APIs de HttpClient. httpResource está pensado para leer datos de forma reactiva; escribir es otro problema, con otro ciclo de vida y otros requisitos de control de errores.

    ¿Qué versión de Angular necesito para cada opción?

    httpResource es estable desde Angular 22.0 y vive en el paquete de HTTP común, así que no tienes que instalar nada. El adaptador de TanStack Query declara compatibilidad desde Angular 16 en adelante, aunque su propia guía de testing avisa de que esa integración requiere Angular 19 o superior, porque las versiones anteriores no soportan PendingTasks.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Migrar de RxJS a Angular Signals: patrones de refactorización

    Migrar de RxJS a Angular Signals: patrones de refactorización

    En 2022 audité un componente de catálogo en Angular.

    Tenía 350 líneas de TypeScript. Doce BehaviorSubject, siete combineLatest, cuatro operadores switchMap anidados y tres pipes async duplicados en la plantilla HTML.

    El equipo se quejaba de dos problemas: primero, la aplicación se ralentizaba cada vez que el usuario tecleaba en el buscador por la sobrecarga de Zone.js. Segundo, cada tres semanas aparecía un bug de sincronización porque alguien olvidaba desuscribirse de un stream y causaba un memory leak.

    El problema no era RxJS, y migrar de RxJS a Angular Signals tampoco significa borrarlo del package.json. RxJS es una librería excelente para flujos asíncronos y eventos complejos. El error fue usarlo como gestor de estado síncrono en la interfaz.

    Migrar bien consiste en devolverle a cada herramienta el trabajo que sabe hacer: Signals para el estado síncrono, RxJS para los flujos asíncronos de verdad.

    Aquí tienes la guía paso a paso, patrón por patrón. Si quieres el contexto de cómo hemos llegado hasta aquí, lo conté en de callbacks a Signals: la reactividad real del frontend.


    Tabla de Equivalencias: De RxJS a Signals

    RxJS (Antiguo para Estado)           Angular Signals (Moderno)
    ─────────────────────────           ─────────────────────────
    BehaviorSubject<T>(value)     ───>  signal<T>(value)
    Observable derivado (map)     ───>  computed(() => ...)
    combineLatest([a$, b$])       ───>  computed(() => a() + b())
    Subscription manual / tap     ───>  effect(() => ...)
    Observable HTTP               ───>  rxResource() / httpResource()
    

    Patrón 1: De BehaviorSubject a signal()

    En lugar de crear un subject privado y exponer un observable público:

    // ❌ Antes (RxJS tradicional)
    @Injectable({ providedIn: 'root' })
    export class CartServiceOld {
      private readonly _items$ = new BehaviorSubject<CartItem[]>([]);
      readonly items$ = this._items$.asObservable();
    
      addItem(item: CartItem): void {
        const current = this._items$.getValue();
        this._items$.next([...current, item]);
      }
    }
    

    La versión moderna con Signals reduce la fricción a una sola línea declarativa:

    // ✅ Ahora (Angular Signals)
    @Injectable({ providedIn: 'root' })
    export class CartService {
      readonly items = signal<CartItem[]>([]);
    
      addItem(item: CartItem): void {
        this.items.update(current => [...current, item]);
      }
    }
    

    Sin necesidad de pipes async, sin desuscripciones en ngOnDestroy y con lectura síncrona inmediata mediante this.items().


    Patrón 2: De combineLatest a computed()

    Calcular valores derivados con RxJS requería combinar flujos y recordar filtrar valores nulos iniciales:

    // ❌ Antes (RxJS)
    readonly totalPrice$ = this.items$.pipe(
      map(items => items.reduce((acc, item) => acc + item.price * item.quantity, 0))
    );
    

    Con Signals, computed() es memoizado por defecto y solo se recalcula cuando sus dependencias cambian:

    // ✅ Ahora (Signals)
    readonly totalPrice = computed(() =>
      this.items().reduce((acc, item) => acc + item.price * item.quantity, 0)
    );
    

    Patrón 3: Efectos colaterales con effect() sin caer en bucles

    Usa effect() únicamente para logging, sincronización con APIs externas del navegador (como localStorage o Canvas) o analytics.

    El peligro común: Modificar un signal dentro de un effect(). Esto genera bucles reactivos infinitos.

    // ⚠️ Si necesitas leer un signal sin suscribirte a sus cambios, usa untracked:
    effect(() => {
      const currentItems = this.items();
      // Leemos el userId sin que este effect se vuelva a disparar si el usuario cambia
      const userId = untracked(() => this.authService.userId());
      
      analytics.track('Cart Updated', { userId, count: currentItems.length });
    });
    

    Patrón 4: Conexión Asíncrona con rxResource y toSignal

    Para llamadas HTTP y servicios asíncronos que devuelven observables, la interoperabilidad es directa:

    import { Component, inject, signal } from '@angular/core';
    import { rxResource } from '@angular/core/rxjs-interop';
    import { ProductService } from './product.service';
    
    @Component({
      selector: 'app-product-list',
      template: `
        @if (productsResource.isLoading()) {
          <p>Cargando productos...</p>
        } @else if (productsResource.error()) {
          <p class="error">Error al cargar datos</p>
        } @else {
          <ul>
            @for (product of productsResource.value(); track product.id) {
              <li>{{ product.name }} — {{ product.price | currency }}</li>
            }
          </ul>
        }
      `
    })
    export class ProductListComponent {
      private readonly productService = inject(ProductService);
    
      readonly categoryId = signal<string | null>(null);
    
      // rxResource gestiona automáticamente estado de carga, valor y error.
      // Ojo con la firma: la clave es `stream` (no `loader`) y devuelve un Observable.
      readonly productsResource = rxResource({
        params: () => ({ category: this.categoryId() }),
        stream: ({ params }) => this.productService.getProducts$(params.category)
      });
    }
    

    Si tu servicio se limita a hacer un GET y devolver el JSON, ni siquiera necesitas rxResource: httpResource() hace el mismo trabajo con la mitad de código. Deja rxResource para cuando necesites operadores de RxJS dentro del loader.

    Este es el estándar que enseñamos en profundidad en el curso de Angular Moderno, donde construimos aplicaciones completas sin Zone.js (Zoneless) preparadas para producción.

    Para arquitecturas de estado avanzadas con Signal Stores y patrones de persistencia, en Dominicode Labs publicamos repositorios con ejemplos listos para clonar.

    También puedes seguir tutoriales en vídeo sobre Signals en el Canal de YouTube de Dominicode.


    Qué hacer hoy con esto

    Abre tu proyecto de Angular e identifica un componente que tenga al menos dos BehaviorSubject para controlar filtros de búsqueda o modales.

    Refactorízalo a signal() y computed(). Elimina los pipes async del HTML.

    Verás cómo el archivo pierde un 40% de líneas de código y el comportamiento del componente se vuelve completamente predecible en milisegundos.


    Preguntas frecuentes

    ¿Signals reemplaza a RxJS por completo en Angular?

    No. Signals reemplaza a RxJS en la gestión del estado y la reactividad síncrona en la UI. RxJS sigue siendo la herramienta ideal para flujos asíncronos complejos, cancelaciones HTTP (switchMap), debounce de inputs de teclado y websockets.

    ¿Qué ventaja tiene rxResource frente a usar toSignal() con HttpClient?

    rxResource ofrece un manejo integral del ciclo de vida asíncrono, exponiendo automáticamente señales para el estado de carga (isLoading()), el valor obtenido (value()) y los posibles errores (error()).

    ¿Por qué está desaconsejado cambiar señales dentro de un effect()?

    Porque desencadena cascadas de re-renderizado impredecibles y bucles infinitos. Los efectos deben utilizarse exclusivamente para sincronizar con sistemas externos (side effects), no para derivar estado interno.

    ¿Se puede migrar a Signals de forma gradual o hay que reescribir la aplicación entera?

    De forma gradual, servicio a servicio. toSignal() y toObservable() permiten que el código nuevo con Signals y el existente con Observables convivan en el mismo componente, así que puedes migrar un feature por sprint sin bloquear al resto del equipo.

    ¿Qué pasa con el pipe async al migrar a Signals?

    Desaparece. Un signal se lee directamente en la plantilla con items(), sin suscripción ni desuscripción, así que en la migración el async se elimina junto con el ngOnDestroy que existía solo para cerrar streams.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • La arquitectura moderna de Angular en 2026: Cómo los Signals y Signal Forms cambiaron el juego

    La arquitectura moderna de Angular en 2026: Cómo los Signals y Signal Forms cambiaron el juego

    Hace un par de años revisé una aplicación empresarial desarrollada en Angular 14. Tenía más de 80 componentes. Para sincronizar dos campos de un formulario y calcular un total dinámico, el equipo había montado un laberinto de BehaviorSubject, combineLatest, operadores de RxJS como distinctUntilChanged y tuberías async por todas partes.

    Cualquier cambio menor requería tocar 4 archivos distintos. Los desarrolladores pasaban más tiempo luchando contra fugas de memoria por suscripciones no canceladas que construyendo funcionalidades de negocio.

    Cuando refactorizamos esa misma aplicación a las versiones modernas de Angular basándonos en Signals y Signal Forms, eliminamos el 45% del boilerplate y la velocidad de renderizado en el navegador se disparó.

    Angular ha cambiado radicalmente. Si sigues programando en Angular como lo hacías en 2020, te estás perdiendo la mayor revolución de usabilidad en la historia del framework.

    El problema histórico: RxJS en la capa de UI

    RxJS es una librería extraordinaria para manejar eventos asíncronos complejos como websockets, streams de datos o cancelación de peticiones HTTP.

    Sin embargo, usar RxJS para gestionar el estado reactivo simple dentro de un componente de UI era una mala decisión forzada por las limitaciones del framework anterior:

    • Tenías que lidiar con la subscripción y desuscripción explícita (takeUntilDestroyed, async pipe).
    • Zone.js tenía que comprobar el árbol completo de componentes ante cualquier evento, provocando ciclos de detección de cambios innecesarios.
    • La sintaxis resultaba verbosa y confusa para desarrolladores que venían de otros ecosistemas.

    Con el rápido ciclo de releases de Angular, el equipo del framework ha introducido un modelo reactivo síncrono, directo y ultrarrápido: los Signals.

    El nuevo paradigma: Grafo Reactivo con Signals

    En la arquitectura moderna de Angular, la reactividad síncrona dentro del componente se gestiona mediante tres primitivas fundamentales:

    1. signal() (Estado primario)

    Representa un valor reactivo mutable. Al modificar su valor, Angular sabe exactamente qué nodo del DOM depende de él y actualiza únicamente ese elemento.

    const cantidad = signal<number>(1);
    const precioUnitario = signal<number>(25);
    

    2. computed() (Estado derivado)

    Calcula automáticamente un nuevo valor en función de otros signals. Es perezoso (lazy) y memoriza su resultado (memoized), por lo que solo se reevalúa cuando uno de sus signals dependientes cambia.

    const total = computed(() => cantidad() * precioUnitario());
    

    3. effect() (Efectos secundarios)

    Se ejecuta cuando los signals leídos en su interior cambian.

    • Regla de oro de arquitectura: Jamás uses effect() para modificar otro signal o para lógica de negocio. Reservalo exclusivamente para sincronización externa (ej. guardar en localStorage, emitir eventos a librerías de terceros o analytics).

    Como recalcamos en nuestras guías de programación defensiva en TypeScript, tipar de forma estricta los valores de tus signals previene errores de estado en tiempo de ejecución.

    Signal Forms y Zoneless: El salto definitivo

    Dos de las piezas más esperadas en el ecosistema moderno son la integración de Signal Forms y el soporte nativo para aplicaciones Zoneless (sin Zone.js).

    Zoneless Angular

    Al basar la UI en Signals, Angular ya no necesita Zone.js para "mono-patrullar" los eventos del navegador (clicks, timers, peticiones HTTP). El framework sabe con precisión quirúrgica qué componente y qué nodo del DOM ha cambiado. El resultado es un consumo de memoria mínimo y un arranque instantáneo.

    El rol actual de RxJS

    RxJS no ha muerto ni va a desaparecer. La regla de arquitectura actual es sencilla:

    • Estado de UI y componentes: Usa 100% Signals (signal, computed, input, output).
    • Eventos asíncronos complejos y HTTP: Usa RxJS (HttpClient, debounceTime, switchMap) y conviértelo a Signal en la frontera del componente mediante toSignal().
    // Integración perfecta: RxJS hacia la API, Signal hacia la plantilla
    readonly usuarios = toSignal(this.userService.getUsuarios(), { initialValue: [] });
    

    La arquitectura moderna de Angular es más limpia, más rápida de aprender y infinitamente más fácil de mantener que en versiones anteriores.

    Si quieres dominar el desarrollo frontend moderno y la integración con herramientas de IA, descubre los Cursos de Dominicode. Y si quieres aplicar estas arquitecturas en proyectos reales de producción, te invitamos a sumarte a Dominicode Labs.

    Preguntas frecuentes

    ¿Debo migrar todos mis BehaviorSubject a Signals inmediatamente?

    No es necesario hacer una migración destructiva de golpe. Puedes mantener tu capa de servicios en RxJS e ir adoptando toSignal() y signal() en la capa de componentes de forma progresiva.

    ¿Cuándo debo usar RxJS en lugar de Signals?

    Utiliza RxJS cuando necesites coordinar múltiples eventos asíncronos en el tiempo (por ejemplo, autocompletados con debounceTime, polling periódico, cancelación de peticiones anteriores con switchMap o gestión de WebSockets).

    ¿Cómo afecta el uso de Signals al SEO y SSR en Angular?

    Los Signals mejoran el rendimiento de Server-Side Rendering (SSR) y Hydration ya que reducen la sobrecarga de evaluación de cambios en el servidor y permiten una hidratación parcial ultrarrápida en el cliente.

    ¿Qué versión de Angular se recomienda para trabajar con Signals de forma estable?

    Signals se introdujo como developer preview en Angular 16 y alcanzó estabilidad en Angular 17/18. Para contar con APIs maduras como input(), output(), model() y Signal Forms se recomienda usar Angular 19/20+.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Ciclo de releases de Angular: una major cada 12 meses

    Ciclo de releases de Angular: una major cada 12 meses

    El 24 de julio de 2026 se mergeó un PR en el repositorio de Angular. Número 69817. Título: docs: add v23 release and change to yearly release cycle.

    Un PR de documentación. Sin keynote, sin post oficial, sin hilo en X. Una línea de docs sobre el ciclo de releases de Angular, mergeada en main y backporteada a las ramas 22.0.x y 22.1.x.

    Dentro va un cambio que decide cómo planificas los próximos tres años de tu proyecto: se pasa de una major cada seis meses a una major cada doce.

    Se mergeó el 24 de julio de 2026 y apenas ha circulado. La mayoría de los equipos se va a enterar el día que abra el calendario del año que viene para reservar su ventana de migración y no encuentre nada donde esperaba encontrar una major.

    En corto: desde Angular v22, Angular publica una versión major cada 12 meses en lugar de cada 6. La siguiente major, Angular v23, está fechada para junio de 2027. Entre medias llegan de 4 a 6 minors con features nuevas, y los patches siguen saliendo casi cada semana.

    Qué cambia exactamente en el ciclo de releases de Angular

    Angular publica una major cada 12 meses desde la v22. Antes eran 6. Los datos son cortos:

    Tipo de release Hasta Angular v21 Desde Angular v22
    Versión major cada 6 meses cada 12 meses
    Minors por cada major 1-3 4-6
    Patches y pre-releases (next / rc) casi cada semana casi cada semana
    Soporte activo por major ~6 meses 12 meses
    Soporte total (activo + LTS) ~18 meses 24 meses

    Fuente: Versioning and releases, documentación oficial de Angular.

    La justificación oficial, traducida:

    "La comunidad lleva mucho tiempo pidiendo majors menos frecuentes por el impacto que tienen los breaking changes y las actualizaciones, tanto en sus proyectos como en clientes enterprise. Además, un ciclo de release más largo proporciona mayor estabilidad de API para los developers que usan flujos de trabajo agénticos, sin dejar de entregar una cadencia razonable de mejoras y migraciones de API."

    — PR #69817, repositorio oficial de Angular.

    Dos motivos. El segundo es el interesante, y vuelvo a él más abajo.

    ¿Cuándo sale Angular v23? El calendario del nuevo ciclo

    Angular v23.0 está fechada para junio de 2027, según el calendario oficial de releases. Hasta entonces la v22 recibe entre 4 y 6 minors —de la v22.1 a la v22.5— programadas hasta principios de 2027.

    Versión Fecha
    Angular v22.0 junio de 2026 (publicada)
    Minors v22.1 – v22.5 hasta principios de 2027
    Angular v23.0 ~ junio de 2027

    Y el ciclo anual también alarga el soporte. Cada major pasa a tener 24 meses de vida: 12 de soporte activo, con actualizaciones y parches regulares, y 12 más de LTS, solo con fixes críticos y de seguridad. En la práctica, Angular v22 tiene soporte activo hasta junio de 2027 y LTS hasta junio de 2028; la v21, con el ciclo viejo, tuvo activo poco más de seis meses.

    Tienes más margen. Pero el salto que te espera al final del margen es el doble de grande.

    Lo que no cambia: las features siguen saliendo en las minors

    Angular no ha frenado el desarrollo: las features siguen saliendo en las minors. Este es el malentendido que va a circular la semana que viene, así que lo corto ya.

    Lo único que queda restringido a las majors son los breaking changes. Las features siguen entrando por las minors, exactamente igual que hasta ahora.

    Y como las minors pasan de 1-3 por major a 4-6, el goteo de novedades no se corta. Si acaso, se estira.

    Lo que baja no es la velocidad de features. Es la frecuencia con la que Angular te obliga a tocar tu código.

    Si has seguido las novedades de Angular v22, ya sabes que el grueso de lo interesante llega goteando y no de golpe en el día de la major.

    Por qué el ciclo anual no te quita el trabajo de migración: cambia cuándo llega

    Un ciclo anual reduce el número de migraciones. Que reduzca el trabajo está por demostrar. Lo que hace seguro es agruparlo. Y es lo contrario de lo que vas a leer en los resúmenes de la noticia.

    Antes tenías un salto pequeño cada seis meses. Ahora tienes uno grande cada doce.

    Lo que baja seguro es el número de veces que paras: de seis eventos de migración en tres años a tres. Lo que no está anunciado en ninguna parte es que baje el volumen de cambios de API que tienes que absorber. Angular no ha dicho que vaya a romper menos cosas. Ha dicho que las va a agrupar.

    Puede que agrupar reduzca algo el total —dos cambios sobre la misma API en doce meses te llegan ya resueltos en uno—, pero eso está por ver. Lo que está decidido hoy es el reparto.

    Y el reparto no afecta igual a todos.

    Si vas al día, ganas. La mitad de migraciones que rompen algo, la mitad de ceremonias de actualización, la mitad de veces que paras un sprint para ejecutar ng update y arreglar lo que se ha roto. Para un equipo con disciplina, esto es dinero directo.

    Si arrastras retraso, pierdes. Un salto de doce meses no son dos saltos de seis pegados: es un salto en el que no puedes bisecar. Cuando algo se rompe tienes el doble de superficie donde buscar la causa, y las migraciones automáticas dejan de llevarte de la mano versión a versión. Si vienes de v17 o v18, ya hice el mapa de qué migrar primero, y ese orden importa más ahora que antes. Ya conté también el coste real de migrar a Angular 22, y ese coste no se reparte a la mitad por espaciarlo.

    Pero el problema de verdad no es el tamaño del salto.

    Es que una ventana más larga hace mucho más fácil quedarse atrás sin notarlo.

    Cuando la siguiente major está a seis meses, el retraso duele pronto. Te saltas una y en medio año ya tienes a alguien preguntando por qué seguís dos versiones por detrás.

    Cuando está a un año, la deuda no avisa. Las minors siguen ahí, pero una minor no te obliga a nada: la ignoras y no pasa nada. Lo que desaparece no son los avisos. Es el único aviso que no podías ignorar. Y la deuda técnica que no genera incomodidad es justo la que nadie prioriza.

    El ciclo anual es más cómodo. Y la comodidad, en gestión de dependencias, casi siempre se paga después.

    Por qué Angular justifica el ciclo anual con los agentes de IA

    Angular justifica su nueva cadencia, en parte, por cómo trabajan los agentes de código. Vuelve a la cita oficial y lee la segunda mitad: "proporciona mayor estabilidad de API para los developers que usan flujos de trabajo agénticos".

    Piensa en lo que significa. Un modelo que te ayuda a escribir Angular ha aprendido de código de muchas versiones a la vez. Cuando las APIs cambian cada seis meses, la probabilidad de que te proponga algo que ya no existe —un decorador retirado, una firma vieja, un patrón de la versión anterior— sube. Y ese código llega a tu PR con aspecto perfectamente razonable.

    Cada breaking change es ruido en lo que el modelo cree saber.

    Con dos matices que conviene poner encima de la mesa, porque son los que hacen el argumento correcto en vez de solo bonito.

    El primero: lo que pesa no es la frecuencia en abstracto, es cuántas majors han pasado desde el corte de entrenamiento del modelo que tienes abierto. Con majors anuales ese número se parte por la mitad, y un modelo de la misma edad sigue estando en lo cierto durante el doble de tiempo.

    El segundo: las versiones viejas no desaparecen. Angular 14 sigue en el corpus y seguirá. Bajar la cadencia no borra lo aprendido, solo frena lo que se acumula a partir de ahora. El efecto es real, pero se mide en años, no en el próximo release. Y un agente que trabaja con la documentación en contexto depende mucho menos de lo que recuerde: la cadencia importa sobre todo cuando el modelo tira de memoria.

    Que un framework grande diga esto en voz alta, aunque sea en un PR de documentación, es nuevo. Hasta ahora la cadencia se discutía pensando solo en humanos: cuánto aguanta un equipo, cuánto tarda una empresa en aprobar una subida de versión.

    Ahora entra un tercer actor en la conversación, y es el que escribe una parte creciente del código.

    No es una nota al pie. Es una señal de cómo se van a diseñar las herramientas los próximos años: asumiendo que quien las consume no siempre es una persona. Es una de las conversaciones que más tenemos en Dominicode Labs, porque cambia decisiones de arquitectura que hasta ayer parecían cerradas.

    Cómo planificar la migración de Angular con el nuevo ciclo de releases

    Tres cambios concretos en tu forma de trabajar.

    1. La actualización deja de ser un evento reactivo y pasa a ser una fecha

    Antes funcionaba el "ya actualizaremos cuando salga la siguiente". Con una major cada seis meses, ese reflejo te mantenía razonablemente cerca del head. Con una cada doce, ese mismo reflejo te regala un año entero para no hacer nada.

    Pon la ventana ahora. Angular v23 está fechada para junio de 2027: reserva las semanas en el roadmap antes de que el trimestre se llene de otra cosa.

    Y si no eres tú quien decide el roadmap, la versión pequeña es abrir el issue con las dos fechas escritas y dejarlo ahí. Cuando llegue la discusión —y va a llegar—, ya existe un sitio donde el tema está planteado y con tu nombre encima.

    2. Trata las minors como el ritmo real de adopción

    Con 4-6 minors por año, la minor es la unidad de trabajo, no la major. Un equipo que va absorbiendo minors llega a la major con medio trabajo hecho: los deprecation warnings ya los ha visto, las migraciones automáticas ya las ha corrido, el código nuevo ya usa las APIs nuevas.

    Un equipo que solo se mueve en majors llega a junio de 2027 con doce meses de sorpresas juntas.

    3. Asume que saltarte una major te deja el doble de lejos que antes

    Saltarte una major te ponía a doce meses de distancia del head. Ahora te pone a veinticuatro. Ese es el número que tienes que enseñar a quien decida si hay tiempo para actualizar o no.

    Un gesto concreto para empezar: ejecuta ng update sin argumentos. Te lista qué paquetes tienen actualización pendiente y a qué versión, sin tocar nada. Es el inventario en treinta segundos.

    4. Monta la red antes del salto, no durante

    Lo que convierte un salto grande en algo manejable es tener algo que te diga en cinco minutos qué se ha roto. Si tu proyecto sigue en Karma, Vitest ya es el default en Angular 22, y ese cambio se hace mejor ahora, con tiempo, que en mitad de la migración a la v23. Cómo montar esa suite —los patrones son los mismos con Jest o con Vitest— lo cubro paso a paso en el curso de Testing en Angular.

    Lo mismo aplica al resto del stack: migrar a TypeScript 6.0 con strict antes del salto convierte errores de runtime en errores de compilación, que es donde los quieres. Y si estás mirando más adelante, TypeScript 7.0 y su compilador en Go es la otra pieza del calendario que conviene tener en el radar.

    Lo que yo haría esta semana

    Abre el roadmap y escribe dos fechas: la de tu próxima adopción de minor y la ventana de la v23 en junio de 2027.

    Diez minutos. Es la diferencia entre llegar a la v23 con una actualización planificada o con una emergencia.

    Si estás poniendo al día un proyecto y quieres el mapa completo de lo que ha cambiado —signals, zoneless, el modelo mental nuevo—, lo tienes ordenado en el curso de Angular Moderno.

    El ciclo anual te ha dado más margen. Y el margen sin fecha no es margen: es aplazamiento.

    Preguntas frecuentes sobre el ciclo de releases de Angular

    ¿Cada cuánto sale ahora una versión major de Angular?

    Angular publica una versión major cada 12 meses a partir de la v22. Hasta ese cambio, el ciclo era de una major cada 6 meses. Entre major y major salen ahora entre 4 y 6 minors, frente a las 1-3 del ciclo anterior.

    ¿Hasta cuándo tiene soporte Angular 22?

    Angular v22 tiene soporte activo hasta junio de 2027 y soporte LTS hasta junio de 2028. Con el ciclo anual, cada versión major pasa a tener 24 meses de soporte en total: 12 meses de fase activa, con actualizaciones y parches regulares, y 12 meses de LTS, en los que solo se publican fixes críticos y de seguridad.

    ¿Cuándo sale Angular v23?

    Angular v23.0 está fechada para junio de 2027 aproximadamente, según el calendario oficial de releases. Hasta entonces las novedades llegan por las minors de la v22: de la v22.1 a la v22.5, programadas hasta principios de 2027.

    ¿Significa esto que Angular se ha ralentizado?

    No. Las features siguen saliendo en las minors, y ahora hay más minors por ciclo: entre 4 y 6 en lugar de 1-3. Lo único que queda restringido a las versiones major son los breaking changes. Los patches y las pre-releases next y rc siguen publicándose casi cada semana, igual que antes.

    ¿Qué pasa si voy retrasado varias versiones de Angular?

    Un proyecto retrasado sale perdiendo con el ciclo anual, porque cada salto de major concentra doce meses de breaking changes en lugar de seis. Además, una ventana más larga hace más fácil acumular retraso sin darse cuenta: no hay una major cercana que recuerde lo lejos que estás. Si arrastras versiones, ponte al día antes de junio de 2027 y hazlo en saltos pequeños, versión a versión, en vez de esperar a hacerlo todo junto.

    ¿Conviene esperar a Angular v23 para migrar?

    No conviene esperar. Llegar a junio de 2027 con el trabajo de la v22 sin hacer significa sumar dos saltos en una sola operación, con el doble de superficie que puede romperse a la vez. La estrategia que funciona es adoptar las minors de la v22 según salen y presentarse en la major con las migraciones automáticas ya ejecutadas y los deprecation warnings ya resueltos.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    Cambié a happy-dom la mitad de la suite que toca el DOM, un martes por la mañana. Era la última pieza de un cambio que dejó el job de CI en 6m 15s, desde los doce minutos que tardaba esa misma mañana. Me sentí muy listo.

    Dos semanas después, un componente de lazy loading llegó roto a producción. El test seguía en verde. Lo ejecuté cincuenta veces y cincuenta veces me dijo que todo estaba bien.

    El problema no era el test. Era el suelo sobre el que corría. Elegir entre happy-dom o jsdom no es una micro-optimización de CI: es decidir qué mentiras está autorizada a contarte tu suite.

    Con jsdom, ResizeObserver no existe. El test revienta con un error escandaloso, instalas un mock, el mock dispara el callback y el test comprueba algo de verdad. Con happy-dom, ResizeObserver sí existe: es una clase que se instancia sin quejarse y cuyos tres métodos están vacíos por dentro. El callback no se llama jamás.

    Mi setup tenía una guarda del tipo if (typeof window.ResizeObserver === 'undefined') para instalar el mock. Con happy-dom esa condición no se cumplía nunca. El mock no se instalaba. El test verificaba el vacío.


    Resumen rápido

    • En tests unitarios, el runner (Vitest, Jest) no es el entorno. El entorno es la librería que emula el navegador debajo: jsdom, happy-dom o ninguna.
    • Vitest arranca por defecto en node, sin DOM. Jest también. El DOM siempre lo pides tú.
    • En mi benchmark, happy-dom resultó 1,77x más rápido que jsdom (mediana de 5 pares A/B, rango 1,45x–1,96x), no las 5x-10x que circulan por ahí.
    • Ninguno de los dos tiene motor de layout. getBoundingClientRect() devuelve ceros en ambos. Cambiar de entorno no arregla eso.
    • happy-dom cubre más superficie de API moderna que jsdom (matchMedia, showModal, scrollIntoView), pero incluye stubs mudos que fingen existir.
    • Regla: node por defecto, happy-dom para tests de componente, jsdom fichero a fichero cuando algo se rompa. Se mezclan en el mismo proyecto.

    El runner no es el entorno

    Esta confusión cuesta tardes enteras.

    Cuando escribes environment: 'jsdom', Vitest instancia un window completo por cada fichero de test y lo inyecta en el contexto global antes de importar tu código. El runner orquesta. El entorno es quien finge ser un navegador.

    Y por defecto no hay ninguno: Vitest arranca en node por defecto, sin document ni window. Jest hace lo mismo desde la versión 27, y desde la 28 ni siquiera trae jsdom — hay que instalar jest-environment-jsdom a mano.

    Angular es el caso más traicionero. Desde que Vitest se convirtió en el test runner por defecto en Angular 21, el builder @angular/build:unit-test detecta qué tienes instalado: si encuentra happy-dom lo usa, y si no, cae a jsdom. Basta con que alguien añada happy-dom al package.json por cualquier motivo para que toda tu suite cambie de suelo sin que nadie toque un fichero de configuración.

    Si vienes de Karma, ese cambio de suelo es la parte que menos se cuenta y más duele. Lo desarrollé en Vitest en Angular 22: por qué Karma ya no es el default.


    Qué es jsdom y qué es happy-dom, sin marketing

    jsdom es la implementación de referencia. Se publicó por primera vez en noviembre de 2011: casi quince años de historia y la base sobre la que se ha testeado medio ecosistema JavaScript. Su norma es la fidelidad a la especificación: si algo está implementado, se comporta como en el navegador; si no puede implementarlo bien, prefiere no implementarlo. Su documentación deja layout y navegación explícitamente fuera de alcance. Versión actual: 29.1.1, del 30 de abril de 2026. Nada nuevo desde entonces.

    happy-dom es un emulador con otra prioridad: arrancar rápido y cubrir lo que los frameworks modernos usan de verdad. Versión 20.11.1, del 22 de julio de 2026, con nueve versiones publicadas entre el 3 de junio y esa fecha.

    En disco: jsdom instala 25 MB y 21 dependencias directas; happy-dom, 19 MB y 7. La diferencia real es mucho menor de lo que sugieren las comparativas que verás por ahí.


    Benchmark happy-dom vs jsdom: lo medí en vez de citarlo

    Circulan cifras de "5x-10x más rápido" que nadie respalda. Monté la prueba, y publico los datos crudos para que puedas comprobar cada división.

    Metodología — benchmark ejecutado por Bezael Pérez (Dominicode) el 24 de julio de 2026:

    Carga 50 ficheros × 3 tests con DOM real: listas de 100 nodos, eventos con dispatchEvent, 200 mutaciones de clases y atributos
    Protocolo 5 pares de ejecuciones alternando A/B (jsdom, happy-dom, jsdom, happy-dom…) para anular la deriva de carga de la máquina
    CPU Intel i7-11700K, 16 hilos, 32 GB RAM, Windows 11
    Runtime Node 24.16.0
    Runner Vitest 4.1.10
    Entornos jsdom 29.1.1 · happy-dom 20.11.1

    Datos crudos. Tiempo total de suite, par a par:

    Par jsdom happy-dom Ventaja
    1 26,47 s 14,93 s 1,77x
    2 21,94 s 15,11 s 1,45x
    3 26,90 s 15,77 s 1,71x
    4 15,70 s 8,56 s 1,83x
    5 16,27 s 8,31 s 1,96x

    La ventaja de happy-dom es 1,77x, la mediana de esos cinco ratios.

    Ojo con un detalle que despista, porque yo mismo tropecé con él: la mediana de los tiempos de jsdom (21,94 s) dividida entre la mediana de los de happy-dom (14,93 s) da 1,47x. Pero esas dos medianas salen de pares distintos —la primera del par 2, la segunda del par 1— y dividirlas mezcla ejecuciones que no compartieron condiciones de máquina. En un diseño A/B emparejado, el estimador correcto es el ratio dentro de cada par, y su mediana es 1,77x.

    Con ese mismo criterio, el resto de métricas:

    Métrica jsdom (mediana) happy-dom (mediana) Ventaja por par
    Tiempo total de suite 21,94 s 14,93 s 1,77x
    Ejecución pura de los tests 4,29 s 1,74 s 2,5x
    Arranque del entorno (acumulado) 235,6 s 119,0 s 2,2x

    Y ahora el dato que de verdad cambia decisiones. Segunda suite, 50 ficheros con un único expect(1 + 1).toBe(2), que aísla el coste de levantar el entorno:

    Entorno Tiempo total Arranque por fichero
    node 1,55 s ~0,2 ms
    happy-dom 4,20 s ~0,75 s
    jsdom 7,71 s ~1,55 s

    Las dos tablas no miden lo mismo y no debes cruzarlas: el arranque acumulado de la primera incluye montar y desmontar un documento con cientos de nodos por fichero, con los 16 hilos saturados; la segunda mide levantar un DOM vacío. Compara cada tabla consigo misma.

    Dicho eso, léelo dos veces. Pasar de jsdom a happy-dom te da 1,8x. Pasar de jsdom a ningún DOM te da 5x.

    La optimización más rentable de tu suite no es cambiar de emulador. Es dejar de cargar un emulador en los tests que no tocan el DOM: reducers, servicios, validadores, utilidades puras. Esos no necesitan window, y probablemente son el 70% de tu suite.


    Dónde te rompe cada uno: qué APIs faltan en jsdom y en happy-dom

    Ejecuté el mismo fichero de sondeo en los dos entornos. Esto es lo que devolvió, no lo que dice la documentación:

    API jsdom 29.1.1 happy-dom 20.11.1
    getBoundingClientRect() todo a 0 todo a 0
    offsetWidth / offsetTop 0 0
    getComputedStyle() con estilos inline correcto correcto
    window.matchMedia no existe sí
    Element.scrollIntoView no existe sí
    document.elementFromPoint no existe sí
    dialog.showModal() no existe sí
    CSS.supports no existe sí
    navigator.clipboard no existe sí
    ResizeObserver no existe stub que nunca dispara
    IntersectionObserver no existe stub que nunca dispara
    canvas.getContext('2d') null, o real con el paquete canvas null, sin alternativa
    Element.animate (WAAPI) no existe no existe
    Custom elements y Shadow DOM sí sí

    Un matiz sobre dialog: jsdom sí define el constructor HTMLDialogElement, pero showModal, show y close no están en el prototipo. No es que lancen una excepción propia: es que 'showModal' in dialog devuelve false.

    Tres conclusiones incómodas.

    Una: el relato de "jsdom es más completo" es falso tal y como se cuenta. En superficie de API moderna gana happy-dom. jsdom sigue sin matchMedia en 2026, probablemente el mock más copiado y pegado de la historia del frontend.

    Dos: ninguno tiene layout. Si tu test necesita que getBoundingClientRect() devuelva algo distinto de cero, cambiar de entorno no te salva.

    Tres, la que me costó el susto: happy-dom prefiere un stub silencioso a un fallo ruidoso. Este es su ResizeObserver real, tal cual está en el repositorio:

    export default class ResizeObserver {
      public observe(): void {
        // TODO: Not implemented
      }
      public unobserve(): void {
        // TODO: Not implemented
      }
      public disconnect(): void {
        // TODO: Not implemented
      }
    }
    

    IntersectionObserver sigue el mismo patrón: guarda el callback en el constructor y expone un takeRecords() que devuelve siempre un array vacío, pero observe() tiene el cuerpo igual de hueco. Tu código lo instancia, llama a observe(), no pasa nada y el test sigue adelante. Un fallo ruidoso cuesta diez minutos. Uno silencioso cuesta un incidente.

    En lo fundamental son gemelos: probé validación de formularios, sanitización de input[type=number], ciclo de vida de custom elements, <template>, orden de propagación capture/bubble, resolución de URLs relativas y parseo de HTML mal formado. Resultado idéntico en ambos. Para el 95% de los tests de componente da exactamente igual cuál uses.


    Cómo se configuran, y cómo se mezclan

    Lo que casi nadie cuenta: no tienes que elegir uno para todo el proyecto. Con Vitest 4 defines proyectos por glob.

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    
    export default defineConfig({
      test: {
        projects: [
          {
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.spec.ts'],
              exclude: ['src/**/*.component.spec.ts'],
            },
          },
          {
            test: {
              name: 'dom',
              environment: 'happy-dom',
              include: ['src/**/*.component.spec.ts'],
            },
          },
        ],
      },
    })
    

    Ese exclude no es decorativo. Sin él, src/**/*.spec.ts también captura los *.component.spec.ts, cada test de componente se ejecuta dos veces —una en node y otra en happy-dom— y la ejecución en node falla con un expected 'undefined' to be 'object' que parece un bug de tu componente y no lo es.

    Y cuando un fichero suelto necesite jsdom, lo declaras en la primera línea. Ese comentario gana a la configuración del proyecto:

    // @vitest-environment jsdom
    import { it, expect } from 'vitest'
    
    it('corre en jsdom aunque el proyecto use happy-dom', () => {
      expect(window.navigator.userAgent).toContain('jsdom')
    })
    

    Lo he verificado ejecutándolo: ese fichero arranca en jsdom mientras el resto de la suite sigue en happy-dom. Un solo test lento no justifica frenar los otros mil.

    En Angular la palanca es distinta, porque el builder elige por ti según lo que esté instalado. Lo robusto es no depender de esa autodetección: apunta la opción runnerConfig del builder a un vitest.config.ts con environment fijado explícitamente, y así da igual lo que aparezca en el package.json. Si prefieres la vía rápida, deja instalado solo uno de los dos:

    # alternativa: fuerza jsdom eliminando la otra opción
    npm uninstall happy-dom && npm install -D jsdom
    

    Y añade esto a tu fichero de setup para que los observers dejen de mentirte:

    // test-setup.ts
    import { vi, beforeEach } from 'vitest'
    
    class ResizeObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) =>
        this.cb([{ target, contentRect: target.getBoundingClientRect() }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
    }
    
    class IntersectionObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) => this.cb([{ target, isIntersecting: true }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
      takeRecords = vi.fn(() => [])
    }
    
    beforeEach(() => {
      vi.stubGlobal('ResizeObserver', ResizeObserverMock)
      vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
    })
    

    Dos clases, no una. Un mock compartido que emite { isIntersecting: true } para ambos revienta en cuanto un componente responsive lee entries[0].contentRect.width, porque esa propiedad no existe en la entry: TypeError: Cannot read properties of undefined. Cada observer tiene su forma de entry y hay que respetarla.

    Y fíjate en el otro detalle: asigno siempre, sin comprobar antes si existe. Esa comprobación es exactamente lo que me llevó a producción con un test verde y un componente roto.


    La regla para elegir entre happy-dom o jsdom

    Cinco pasos, en este orden. Los aplico tal cual.

    1. node por defecto. Si el test no toca document, no cargues DOM. Ahí está el 5x, no en la comparativa de emuladores.
    2. happy-dom para tests de componente. Casi el doble de rápido y con más API moderna cubierta. Es la elección por defecto en 2026.
    3. Nunca uses guardas del tipo if (typeof window.X === 'function') en el setup. Sobrescribe siempre los observers con mocks que disparen.
    4. jsdom fichero a fichero, no suite entera. ¿Un test necesita canvas real o un comportamiento de spec que happy-dom aproxima mal? // @vitest-environment jsdom en la línea 1 y sigues.
    5. Si necesitas layout de verdad, ningún emulador sirve. Posiciones reales, scroll real, capturas visuales: eso es Browser Mode de Vitest, estable desde la 4.0, con Playwright debajo. Más lento, y el único sitio donde esos tests significan algo.

    La excepción que invierte los pasos 2 y 4: si mantienes una librería de componentes que consumen otros, empieza en jsdom. Ahí prefieres un fallo ruidoso a una aproximación cómoda, porque el coste de un falso verde no lo pagas tú.

    Razonar sobre el entorno antes que sobre el aserto es la columna vertebral del curso de Testing en Angular, donde monto la suite desde cero decidiendo qué corre en node, qué en DOM emulado y qué en navegador real. Y si lo que te falta es la base del framework antes de entrar a testearlo, esa parte la cubro en el curso de Angular Moderno.


    Lo que puedes hacer hoy

    Abre tu vitest.config.ts y mira qué environment tienes a nivel global para tus tests unitarios.

    Si es jsdom o happy-dom para toda la suite, acabas de encontrar tu mayor ganancia de tiempo del trimestre: sepáralo en dos proyectos y manda a node todo lo que no toque document. Diez minutos de trabajo.

    Después añade el mock de los observers al setup. Porque el test que más te va a costar en tu carrera no es el que falla: es el que pasa por el motivo equivocado.

    Si quieres seguir tirando del hilo, tengo publicado Testing en Angular con IA: tests que protegen de verdad, donde ataco el mismo problema desde el otro lado. Y si prefieres verlo montado sobre un proyecto real y con gente a la que preguntar, te espero en Dominicode Labs.


    Preguntas frecuentes sobre happy-dom y jsdom

    ¿Qué es más rápido, happy-dom o jsdom?

    happy-dom. En el benchmark que ejecuté en Dominicode en julio de 2026 con Vitest 4.1.10, sobre 50 ficheros con manipulación real de DOM y cinco pares de ejecuciones alternadas, happy-dom resultó 1,77x más rápido que jsdom en mediana, con un rango de 1,45x a 1,96x. En ejecución pura de operaciones DOM la ventaja sube a 2,5x y en arranque del entorno es de 2,2x. Las cifras de "5x o 10x" que circulan no se corresponden con lo que mide una suite real. La ganancia grande está en no cargar ningún DOM: el entorno node fue 5 veces más rápido que jsdom en la misma máquina.

    ¿Merece la pena migrar de jsdom a happy-dom?

    Depende de dónde esté tu cuello de botella, y casi nunca está donde crees. Si tu suite tarda diez minutos, migrar a happy-dom te deja en unos seis: real, pero no transformador. Antes de eso, mira cuántos de tus tests cargan un DOM sin necesitarlo, porque mover esos a environment: 'node' da una mejora del orden de 5x en esa parte de la suite y no tiene ningún riesgo de compatibilidad. Mi recomendación es hacerlo en ese orden: primero separa node de DOM, después cambia el emulador y, si algún fichero se rompe, pásalo a jsdom con el comentario // @vitest-environment jsdom en lugar de revertir la migración entera.

    ¿Cuál usa Vitest por defecto?

    Ninguno de los dos. El valor por defecto de test.environment en Vitest es node, sin window ni document. Para tener DOM debes instalar jsdom o happy-dom y declararlo en vitest.config.ts o con el comentario // @vitest-environment en la cabecera del fichero. Jest se comporta igual: su entorno por defecto es node y desde Jest 28 hay que instalar jest-environment-jsdom como paquete aparte.

    ¿Qué entorno DOM usa Angular con Vitest?

    El builder @angular/build:unit-test detecta automáticamente qué tienes instalado: prefiere happy-dom si está presente y cae a jsdom si no. Conviene saberlo porque implica que añadir happy-dom al package.json por cualquier motivo cambia el entorno de toda la suite sin que nadie modifique la configuración. Si quieres un comportamiento predecible, fija environment de forma explícita en el fichero de configuración al que apunta la opción runnerConfig del builder, en vez de confiar en la autodetección.

    ¿Por qué mi test falla con "ResizeObserver is not defined"?

    Porque estás en jsdom, que no implementa ResizeObserver ni IntersectionObserver: la propiedad no existe en window. La solución es añadir un mock en el fichero de setup, con una clase distinta para cada uno, porque sus entries tienen forma diferente: contentRect en el de resize e isIntersecting en el de intersection. Ojo con el matiz: en happy-dom esas clases sí existen, pero sus métodos están vacíos y el callback no se ejecuta nunca. Si tu mock está protegido por una comprobación de existencia, en happy-dom no se instalará y tu test pasará sin comprobar nada.

    ¿Puedo usar happy-dom y jsdom en el mismo proyecto?

    Sí, y es la mejor estrategia. Con Vitest 4 defines varios proyectos en test.projects, cada uno con su environment y su glob de ficheros. Cuida los globs: si un proyecto incluye src/**/*.spec.ts y otro src/**/*.component.spec.ts, los ficheros de componente caen en los dos y se ejecutan por duplicado, así que necesitas un exclude en el primero. Además, el comentario // @vitest-environment jsdom en la primera línea de un fichero tiene prioridad sobre la configuración del proyecto, así que puedes mantener toda la suite en happy-dom y mover a jsdom solo los ficheros que lo necesiten. No hay que migrar en bloque.

    ¿Con cuál funciona getBoundingClientRect?

    Con ninguno. Ni jsdom ni happy-dom incorporan motor de layout, así que getBoundingClientRect(), offsetWidth y offsetTop devuelven cero en los dos. La documentación de jsdom lo declara explícitamente fuera de alcance. Si tu test depende de posiciones o tamaños reales, la única salida es un navegador de verdad: Browser Mode de Vitest, estable desde la 4.0, con Playwright por debajo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • TypeScript 7.0: el compilador en Go que cambia tu día a día

    TypeScript 7.0: el compilador en Go que cambia tu día a día

    El año pasado revisé un monorepo Nx de un cliente con más de 600 archivos TypeScript compartiendo tipos entre ocho aplicaciones Angular. Cada vez que alguien tocaba una interfaz común, tsc --noEmit tardaba entre 50 y 90 segundos en confirmar si habíamos roto algo.

    Multiplica eso por cada dev, cada commit, cada CI run del día, y tienes horas enteras de tu equipo mirando una terminal en vez de escribir código.

    Ayer, 8 de julio de 2026, Microsoft anunció que TypeScript 7 llegó a disponibilidad general. Y no es una release más con un par de utility types nuevos.

    Es la primera vez en la historia del lenguaje que el compilador deja de estar escrito en TypeScript y pasa a ser un binario nativo en Go. El proyecto se llamó tsgo durante la beta; en la versión final, tsc ya es ese compilador nativo — no hay que instalar nada aparte.

    Llevamos años escuchando la misma queja en cualquier proyecto TypeScript grande: "esto sería instantáneo si estuviera en Rust o en Go, como esbuild o swc". Microsoft por fin le hizo caso a su propia comunidad, y el resultado es el cambio de infraestructura más importante que ha tenido TypeScript desde que existe.


    Qué cambió realmente en TypeScript 7 (no es un upgrade cosmético)

    Hasta la versión 6.0, tsc era un compilador bootstrapped: TypeScript compilando TypeScript, que a su vez corría sobre el motor de JavaScript de Node. Funcional, pero con un techo de rendimiento que ni V8 ni ningún truco de caché podían romper del todo.

    TypeScript 7 tira ese techo abajo. Microsoft reescribió el type-checker, el parser y el emitter en Go, un lenguaje compilado con gestión de memoria y concurrencia nativa.

    La lógica de chequeo de tipos se mantiene estructuralmente idéntica a la de 6.0 — Microsoft no aprovechó la reescritura para "arreglar" reglas de inferencia. Si tu código compilaba limpio en 6.0 con stableTypeOrdering activado y sin flags deprecados, debería compilar igual en 7.0.

    TypeScript 6.0 TypeScript 7.0
    Compilador Bootstrapped (TS sobre JS/Node) Nativo en Go (tsgo → tsc)
    Velocidad de type-checking Base ~10x más rápido (16.7x con --checkers 8)
    strict Opcional Obligatorio
    target: es5 Soportado Eliminado
    Módulos amd / umd / systemjs / none Soportados Eliminados (CommonJS sigue vivo)
    baseUrl Soportado Eliminado
    API programática estable Disponible Llega en TypeScript 7.1

    TypeScript 7: los números que sí importan

    Microsoft reporta que TypeScript 7.0 es, en promedio, unas 10 veces más rápido que TypeScript 6.0.

    Pero el dato que de verdad vale la pena mirar es el de VS Code con el flag --checkers 8 (paralelización del type-checker en varios hilos): pasó de 125.7 segundos a 7.51 segundos. Un speedup de 16.7x en el chequeo de tipos de un codebase real y masivo.

    Eso no es "un poco más rápido". Eso es la diferencia entre lanzar un build y perder el foco, versus lanzar un build y ver el resultado antes de levantar la vista de la pantalla.

    Si trabajas en un proyecto Angular grande — de esos donde el IntelliSense empieza a tartamudear pasados los 200 componentes, como los que armamos en la guía de Angular Signal Forms — este es el tipo de mejora que se siente en el editor todos los días, no solo en el CI.

    Si tu proyecto es de ese tamaño, probablemente ya conoces el dolor de mantener una arquitectura de tipos compartidos entre módulos. En el curso de Angular Moderno trabajamos justo ese tipo de estructura — componentes standalone, signals y una capa de tipos que ahora se va a beneficiar directamente de un compilador que deja de ser el cuello de botella.

    ¿Rompe mi código? Sí, pero no donde crees

    La lógica de inferencia de tipos no cambió. Lo que cambió es que TypeScript 7 convierte en obligatorio todo lo que en 6.0 era opcional o estaba deprecado. Concretamente:

    • strict mode ya no es una opción — es el default forzado.
    • Desaparecen target: es5, downlevelIteration y, como valores de module, AMD, UMD, SystemJS y none (se recomienda esnext o preserve). CommonJS sigue soportado.
    • baseUrl se elimina; los imports relativos tienen que ser explícitos o pasar por paths.
    • Los template literals ahora preservan code points Unicode reales, en vez de partir emojis en pares de surrogates UTF-16. Un detalle pequeño que puede romper tests de snapshots si comparas strings a nivel de caracteres.

    Si tu proyecto usa validación de esquemas con librerías como Zod, strict obligatorio en realidad juega a tu favor: el compilador ahora exige la misma disciplina de tipos que ya deberías estar aplicando en tus schemas. Si todavía no tienes esa disciplina, este es un buen momento para revisar el curso de Zod para TypeScript antes de que strict te obligue a arreglarlo todo de golpe.

    Antes de migrar a TypeScript 7: el checklist que de verdad importa

    Actualizar con npm install -D typescript instala el nuevo tsc nativo sin fricción. El problema nunca es la instalación — es lo que descubres después de instalarlo:

    1. Revisa tu tsconfig.json. Si el archivo vive fuera del directorio de fuentes (algo común en monorepos), ahora tienes que declarar rootDir de forma explícita. Antes el compilador lo inferías; ahora no.
    2. Declara tus @types en el array types. El comportamiento por defecto cambió — si dependes de tipos globales de paquetes como @types/node o @types/jest, sé explícito o vas a ver errores de "no se encuentra el nombre" en símbolos que antes funcionaban solos.
    // tsconfig.json — antes (TypeScript 6.0, inferido)
    {
      "compilerOptions": {
        // rootDir se infería, types no era obligatorio
      }
    }
    
    // tsconfig.json — después (TypeScript 7.0, explícito)
    {
      "compilerOptions": {
        "rootDir": "./src",
        "types": ["node", "jest"]
      }
    }
    
    1. Si tienes JavaScript con JSDoc, revisa el CHANGES.md del proyecto. Patrones como @enum, el operador postfix ! o sintaxis estilo Closure divergen del comportamiento de 6.0. No es una lista larga, pero si tu proyecto tiene archivos .js documentados con JSDoc, vale la pena los cinco minutos de lectura.
    2. Si necesitas convivir con TS 6.0 — por ejemplo, porque una herramienta de tu stack todavía depende de la API interna — instala el paquete @typescript/typescript6, que expone un ejecutable tsc6 en paralelo.

    Nada de esto es dramático. Pero tampoco es un "npm install y ya". Trátalo como tratarías cualquier upgrade de compilador mayor — o como el cambio de Karma a Vitest en Angular 22: en una rama aparte, con CI corriendo antes de tocar main.

    Lo que todavía no puedes hacer

    Aquí está la letra pequeña que casi nadie está mencionando: la API programática estable de TypeScript 7 — la que usan herramientas como ts-morph, plugins de bundlers o el propio Angular Language Service para chequeo de tipos en templates — no llega hasta la versión 7.1. El GA de hoy es para el CLI, para tsc. No para quien construye herramientas sobre el compilador.

    Eso significa que, por ahora, puedes usar TypeScript 7 para el chequeo de proyecto completo desde la línea de comandos y sacarle el speedup en CI hoy mismo. Pero el chequeo dentro de templates de Angular en tu editor va a seguir dependiendo de TypeScript 6.0 hasta que esa API se estabilice. Es una convivencia perfectamente normal, no una incompatibilidad — simplemente no esperes que todo tu tooling salte a la vez.


    Mi consejo, después de quince años viendo migraciones de compiladores salir mal por prisa: no actualices tu proyecto de producción esta semana solo porque salió el anuncio.

    Crea una rama, instala TypeScript 7, corre tu build y tu suite de tipos, y mide tú mismo la diferencia de tiempo antes de tocar main. El speedup es real, pero el checklist de arriba es lo que separa una migración de una tarde de una migración de una semana apagando incendios.

    Si quieres profundizar en arquitecturas TypeScript grandes y cómo estructurarlas para que este tipo de mejoras de compilador realmente se noten, en Dominicode Labs compartimos los proyectos y patrones que uso en clientes reales, actualizados a medida que el ecosistema cambia.


    Preguntas frecuentes

    ¿Debo actualizar mi proyecto a TypeScript 7 ya?

    Para probar y medir, sí — en una rama separada, no en producción directamente. Para producción, primero revisa el checklist de breaking changes (strict obligatorio, rootDir explícito, array types, eliminación de targets legacy) y corre tu CI completo antes de mergear.

    ¿TypeScript 7 rompe mi código actual?

    La lógica de type-checking es estructuralmente idéntica a la de TypeScript 6.0. Si tu proyecto ya compilaba limpio en 6.0 con stableTypeOrdering y sin usar flags deprecados, debería compilar igual. Lo que sí rompe son los defaults: strict obligatorio, sin target: es5, sin módulos amd/umd/systemjs/none (CommonJS sigue soportado) y sin baseUrl.

    ¿Qué es tsgo?

    Es el nombre que tuvo el proyecto de reescritura del compilador de TypeScript en Go durante su fase de beta y builds nightly. En el release final de TypeScript 7.0, ese compilador nativo en Go es tsc — no existe un binario separado llamado tsgo que tengas que invocar.

    ¿Angular ya es compatible con TypeScript 7?

    Parcialmente. Puedes usar TypeScript 7 desde la CLI para el chequeo de tipos de proyecto completo y aprovechar el speedup en builds y CI hoy mismo. El propio anuncio de lanzamiento de Microsoft admite que las herramientas que embeben TypeScript en su propio compilador — como las que dan soporte a templates de Angular — "probablemente" seguirán dependiendo de TypeScript 6.0 hasta que la API programática estable llegue en la 7.1. Angular todavía no ha publicado su propia matriz de compatibilidad para TS 7, así que confírmalo en su documentación oficial antes de tocar el editor de tu equipo.

    ¿Cuándo llega la API programática estable?

    Microsoft la tiene planificada para TypeScript 7.1, no para este GA de 7.0. Si construyes herramientas sobre el compilador (ts-morph, plugins de build, integraciones de linters), tu código seguirá dependiendo de la API de TypeScript 6.0 hasta esa siguiente versión.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Vitest en Angular 22: por qué Karma ya no es el default

    Vitest en Angular 22: por qué Karma ya no es el default

    Son las 11 de la noche. Hay un commit pendiente de mergear y el pipeline de CI acaba de arrancar.

    Primero levanta el contenedor. Después Chrome headless. Karma detecta los specs, los compila y — casi dos minutos después de tu push — arranca la primera suite.

    Multiplica esos dos minutos por cada PR del día, por cada rebase, por ese "se me olvidó un punto y coma" que te obliga a repetir el ciclo entero.

    No es una exageración. Es el ritual diario de cualquier equipo Angular con Karma en producción. Por eso Vitest en Angular 22 dejó de ser una curiosidad de nicho: es ya el camino que recomienda el propio equipo de Angular.


    Por qué Karma se queda atrás

    Karma no es lento porque esté mal hecho. Es lento porque hace algo que en 2026 ya no tiene sentido: lanzar un navegador real — Chrome, o el que hayas configurado — para ejecutar cada suite de tests.

    Arrancar un navegador tiene un coste. Inicializar el motor de renderizado, cargar las extensiones de test, compilar el bundle con la configuración heredada de karma.conf.js… todo eso pasa antes de que se ejecute el primer expect().

    Y luego está la ejecución. Karma corre las suites de forma secuencial por defecto. Si tienes 40 archivos de spec, esperas a que terminen uno detrás de otro.

    Yo he trabajado en proyectos donde levantar el entorno de Karma tardaba varios minutos, antes de correr un solo test útil. Multiplica eso por cada push a un pipeline que corre veinte veces al día y tienes un cuello de botella silencioso que nadie cuestiona porque "siempre ha sido así".

    Vitest cambia la premisa completa. En lugar de un navegador real, corre en un proceso de Node.js y simula el DOM con una librería de emulación — arranca en milisegundos, no en segundos. Y ejecuta los archivos de test en paralelo por defecto, no de forma secuencial.

    No hace falta inventar un benchmark con un múltiplo llamativo para explicar esto. La diferencia cualitativa ya es suficiente: uno lanza un navegador, el otro no.


    Vitest nativo en Angular 22: lo que es default y lo que no

    Desde Angular 21, Vitest es el framework de testing por defecto para proyectos nuevos creados con ng new. Angular 22 mantiene ese default. Aquí hay que ser preciso, porque el estado real tiene matices que se pierden en los titulares.

    Si generas un proyecto hoy con el CLI — tal y como lo hacemos desde cero en el curso de Angular Moderno —, Vitest ya viene configurado. No instalas nada, no tocas angular.json.

    Karma, por otro lado, sigue soportado oficialmente. No ha sido eliminado ni deprecado. Sigue siendo una opción válida y documentada si tienes un proyecto existente y decides quedarte con él.

    Lo que sí está marcado como experimental es otra cosa distinta: migrar un proyecto existente de Karma a Vitest. La documentación oficial de Angular lo dice sin rodeos: "Migrating an existing project to Vitest is considered experimental".

    Esa distinción importa. Vitest de fábrica en un proyecto nuevo es el camino estándar y recomendado. El proceso de migración de un proyecto legacy con Karma es lo que todavía se etiqueta como experimental. No son lo mismo, y confundirlos te hace tomar decisiones equivocadas sobre cuándo migrar.

    El builder detrás de todo esto se llama @angular/build:unit-test, y se configura en el target test de tu angular.json:

    {
      "projects": {
        "mi-proyecto": {
          "architect": {
            "test": {
              "builder": "@angular/build:unit-test"
            }
          }
        }
      }
    }
    

    Requiere el sistema de compilación application, que ya es el default en cualquier proyecto nuevo. Sus valores por defecto son "tsConfig": "tsconfig.spec.json" y "buildTarget": "::development" — no necesitas escribirlos a mano salvo que quieras cambiarlos.

    ¿Y el DOM? Vitest corre tus tests en un entorno Node.js, no en un navegador. Para simular document, window y el resto de la API del navegador usa una librería de emulación. El Angular CLI detecta automáticamente happy-dom si lo tienes instalado; si no, cae a jsdom como fallback.


    Cómo migrar un proyecto existente

    Si tu proyecto ya existe y corre sobre Karma, migrar no es instantáneo, pero tampoco es una reescritura. Son cinco pasos:

    1. Instala las dependencias: npm install --save-dev vitest jsdom
    2. Cambia el builder del target test en angular.json a @angular/build:unit-test
    3. Revisa tu karma.conf.js en busca de configuraciones custom y trasládalas a un vitest.config.ts
    4. Elimina karma.conf.js y src/test.ts, y desinstala los paquetes de Karma (karma, karma-chrome-launcher, karma-coverage, karma-jasmine, etc.)
    5. Opcional: si necesitas correr tests en un navegador real (modo browser), instala @vitest/browser-playwright y añade "browsers": ["chromium"] en la configuración

    Ahora el gotcha que rompe configuraciones cuando nadie lo espera.

    Con el builder viejo de Karma, podías meter tus opciones de build — polyfills, assets, estilos — directamente dentro del target test. Era cómodo, y casi nadie se paraba a pensar si estaba bien hecho.

    El builder nuevo, @angular/build:unit-test, no soporta eso. Si las opciones de build que necesitas para tus tests son distintas de las de tu configuración normal de desarrollo, tienes que sacarlas de ahí y crear una configuración de build dedicada — normalmente un target development separado que el builder de test referencia.

    Si tu proyecto tenía cualquier personalización de polyfills o assets dentro del target test, este es exactamente el punto donde la migración "automática" deja de serlo.


    El schematic que automatiza parte del trabajo

    Angular no te deja solo con los cinco pasos manuales. Existe un schematic que hace la parte mecánica de convertir sintaxis Jasmine a Vitest:

    ng generate @schematics/angular:refactor-jasmine-vitest --project mi-proyecto --add-imports
    

    Convierte automáticamente patrones como fit/fdescribe a it.only/describe.only, spyOn a vi.spyOn, jasmine.any a expect.any, y otras conversiones de sintaxis equivalentes.

    Opciones útiles: --project <nombre> para apuntar a un proyecto específico del workspace, --include <path> para limitar el alcance, --add-imports para que añada los imports explícitos de Vitest que necesites, y --browser-mode si estás migrando hacia modo browser.

    Ahora la parte honesta, porque prometerte una migración 100% automática sería mentirte.

    El schematic no instala dependencias — eso lo haces tú a mano. No migra polyfills ni assets — ese es el gotcha del punto anterior, y sigue siendo tu responsabilidad. Y en escenarios de spies complejos — mocks anidados, spies sobre spies, configuraciones de retorno encadenadas — hace su mejor esfuerzo, pero necesitas revisar el resultado a mano.

    Trátalo como un primer pase que te ahorra la mayor parte del trabajo mecánico, no como un botón de "migrar y olvidar".

    Si además ya usas IA para generar o revisar tus tests — algo que cubrimos en testing en Angular con IA —, dale el resultado del schematic a tu agente y pídele que revise específicamente los spies antes de dar la migración por terminada.


    Mapa de equivalencias: de Jasmine/Jest a Vitest

    Necesidad Jasmine/Jest Vitest
    Función simulada jest.fn() / jasmine.createSpy vi.fn()
    Espiar método jest.spyOn() vi.spyOn()
    Mockear módulo jest.mock() vi.mock()
    Import real en mock parcial jest.requireActual() vi.importActual()
    Timers falsos jest.useFakeTimers() vi.useFakeTimers()
    Restaurar mocks jest.clearAllMocks() vi.clearAllMocks()
    Matcher jasmine.any jasmine.any(Type) expect.any(Type)

    Fíjate en el patrón: casi todo lo que cambia empieza con jest. o jasmine. y pasa a vi.. Es el mocking y el motor de ejecución lo que cambia, no la forma de pensar tus tests.

    Los matchers de aserciones — toBe, toEqual, toContain, toThrow, resolves, rejects — funcionan prácticamente igual en Vitest. Si ya sabes escribir un expect() en Jasmine o Jest, sabes escribir uno en Vitest. La curva de aprendizaje no está en las aserciones, está en el mocking.

    Esto es justo lo que no cambia con el motor: los patrones de Testing Library (render, screen, userEvent) y la filosofía de testing por comportamiento en lugar de por implementación.

    Eso es exactamente lo que cubrimos en el curso de Testing en Angular con Jest y Testing Library: sea cual sea el motor de tu proyecto — Jest hoy, Vitest mañana —, cómo piensas un test de comportamiento no cambia.


    Testing zoneless con Vitest

    Angular 22 empuja fuerte hacia zoneless. Y eso cambia también cómo escribes tus tests.

    Con Zone.js, después de simular una interacción — un click, un input — a veces tenías que llamar fixture.detectChanges() manualmente para forzar que Angular actualizara la vista antes de tu expect().

    En modo zoneless no hay Zone.js escuchando cada tarea async para disparar la detección de cambios. En su lugar, usas await fixture.whenStable() para esperar a que el ciclo de detección de cambios asíncrono termine:

    it('actualiza el contador al hacer click', async () => {
      const fixture = TestBed.createComponent(ContadorComponent);
      fixture.nativeElement.querySelector('button').click();
    
      await fixture.whenStable();
    
      expect(fixture.nativeElement.textContent).toContain('1');
    });
    

    Es un cambio pequeño en la sintaxis pero grande en la intención: pasas de forzar la detección de cambios a esperar a que el propio sistema te diga que está estable. Es la misma filosofía que estamos viendo en otras piezas de v22, como Signal Forms — otra API que va madurando y sobre la que conviene ser precisos respecto a qué está ya estable y qué sigue en evolución.


    Karma vs Vitest en Angular 22, cara a cara

    Karma Vitest
    Arranque de suite Lanza un navegador real (Chrome u otro) Corre en Node.js, simula el DOM con happy-dom o jsdom
    Ejecución Secuencial por defecto Paralela por defecto
    Configuración karma.conf.js, heredada de webpack vitest.config.ts, integrada con el builder de Angular
    Estado en Angular 22 Soportado oficialmente, sigue siendo válido Default para proyectos nuevos; migrar proyectos existentes es experimental

    La tesis

    Cambiar de Karma a Vitest no es "un test runner más rápido". Es Angular alineando su tooling de testing con el ecosistema Vite y ESM que ya domina el resto del frontend — y quitándose de encima una dependencia que llevaba años siendo el cuello de botella silencioso de cualquier pipeline: un navegador real corriendo en CI.

    Si estás empezando un proyecto hoy, no tienes nada que decidir — Vitest ya viene puesto. Si tienes un proyecto existente con Karma, tienes una decisión real que tomar, y ahora sabes exactamente qué parte de esa migración es estándar y cuál sigue siendo experimental.

    Repasamos el resto de las novedades de v22 — de las que Vitest es solo una pieza — en el post de novedades de Angular v22. Y si quieres ver cómo aplicamos estos patrones en proyectos reales de producción, en Dominicode Labs es donde compartimos ese trabajo con la comunidad.


    Preguntas frecuentes sobre Vitest en Angular 22

    ¿Vitest reemplaza completamente a Karma en Angular 22?

    Reemplaza a Karma como default para proyectos nuevos, pero no lo elimina. Karma sigue soportado oficialmente y sigue siendo una opción documentada y válida si tienes un proyecto existente que prefieres no migrar todavía.

    ¿Necesito instalar plugins de terceros como Analog para usar Vitest en Angular 22?

    No. El soporte de Vitest está integrado directamente en el Angular CLI a través del builder @angular/build:unit-test. No necesitas ningún plugin de terceros para el flujo estándar — solo instalar vitest y jsdom (o happy-dom) como dependencias de desarrollo.

    ¿Cómo migro mis tests de Jasmine a Vitest automáticamente?

    Con el schematic ng generate @schematics/angular:refactor-jasmine-vitest, que convierte automáticamente la sintaxis de spies, matchers y bloques fit/fdescribe. No es una migración 100% automática: no instala dependencias, no migra polyfills ni assets, y los spies complejos necesitan revisión manual.

    ¿Qué le pasa a mis configuraciones de build al migrar de Karma a Vitest?

    Si tu configuración de build para tests (polyfills, assets, estilos) era distinta de tu configuración normal de desarrollo, no puedes moverla dentro del target test como hacías con Karma. El nuevo builder no lo soporta — tienes que crear una configuración de build dedicada, por ejemplo un target development separado.

    ¿Vitest funciona con testing zoneless en Angular 22?

    Sí, y de hecho es donde más se nota el cambio de paradigma: en lugar de llamar fixture.detectChanges() manualmente tras una interacción, usas await fixture.whenStable() para esperar el ciclo de detección de cambios asíncrono.

    ¿Debería migrar mi proyecto existente a Vitest hoy mismo?

    Si tu suite de tests es grande y crítica para producción, pruébalo primero en una rama o en un proyecto secundario antes de tocar el repo principal — la documentación oficial etiqueta esta migración como experimental. Depende, en última instancia, de tu tolerancia al riesgo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • De callbacks a Signals: la reactividad real del frontend

    De callbacks a Signals: la reactividad real del frontend

    Un excliente me escribió hace años, angustiado. Su carrito de compras mostraba 3 artículos en el header, pero el checkout decía que había 5. Los clientes se quejaban en soporte y algunos abandonaban la compra.

    Revisé el código. Puro estilo jQuery: DOM manipulado a mano, evento por evento. Un event listener actualizaba el contador del header. Otro, completamente separado, actualizaba el resumen del checkout. Nadie los había conectado entre sí — y ahí estaba el problema real: cero programación reactiva, cero garantía de que el estado y la interfaz dijeran la misma verdad.

    Cuando alguien hacía clic dos veces seguidas y rápido, un listener terminaba antes que el otro. El total quedaba repartido entre cuatro variables sueltas, cada una con su propia versión de la verdad. Pasé tres horas arreglando algo que debería haberme tomado diez minutos. No porque el bug fuera complejo — porque nada en el código garantizaba que la interfaz reflejara el estado real.

    Llevamos veinte años resolviendo ese mismo problema con herramientas distintas. Primero fueron callbacks manuales sobre el DOM. Luego llegó el Virtual DOM. Ahora, señales. Cada era resolvió lo que la anterior no pudo — y entender por qué importa más que memorizar sintaxis nueva cada dos años.


    Era 1: callbacks manuales y el DOM que se te olvida sincronizar

    En los tiempos de jQuery — y del DOM vanilla antes de eso — la única forma de reaccionar a un evento era escucharlo y mutar el DOM a mano. Tú decidías qué elemento tocar, cuándo y con qué valor.

    Toma el ejemplo clásico: un contador de carrito con tres elementos que dependen del mismo dato.

    let count = 0;
    
    const counterEl = document.querySelector('#counter');
    const totalEl = document.querySelector('#total');
    const shippingMsgEl = document.querySelector('#shipping-msg');
    
    document.querySelector('#add-btn').addEventListener('click', () => {
      count++;
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      shippingMsgEl.textContent = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    });
    
    document.querySelector('#remove-btn').addEventListener('click', () => {
      count = Math.max(0, count - 1);
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      // shippingMsgEl no se actualiza aquí. Nadie lo notó en code review.
    });
    

    Mira el comentario en la última línea. Ese es, casi literal, el bug que revisé en el carrito de mi excliente.

    No es un error de sintaxis — el código compila, pasa QA si nadie prueba el camino de "quitar un producto cuando ya tenías envío gratis". El bug vive en la cabeza del developer: hay que acordarse de tocar los tres elementos en cada handler que mueva ese estado.

    La ventaja de este modelo es real: control total, cero abstracciones, cero curva de aprendizaje. Para un widget aislado — un acordeón, un modal, un tooltip — sigue siendo la opción correcta hoy mismo.

    El problema aparece en cuanto el estado deja de ser trivial:

    • Cada elemento dependiente necesita su propia línea de sincronización, repetida en cada handler que toque ese estado.
    • El estado vive disperso: a veces en el DOM (el.textContent), a veces en variables sueltas, a veces en atributos data-*.
    • Los listeners no se limpian solos. En una SPA que monta y desmonta vistas, cada addEventListener sin su removeEventListener es un memory leak esperando a pasar factura.

    Esto nunca fue un problema de jQuery. Fue un problema de arquitectura: nada en el modelo te obligaba a centralizar el estado ni a declarar sus dependencias. Cada developer inventaba su propia disciplina — y la disciplina, a escala de equipo, no escala.

    Era 2: Virtual DOM y el modelo declarativo

    React cambió la pregunta. En lugar de "¿qué elemento del DOM tengo que tocar?", pasó a ser "¿cómo se ve la UI dado este estado?". Tú describes el resultado final; el framework decide cómo llegar ahí.

    function Counter() {
      const [count, setCount] = useState(0);
      const total = (count * 19.99).toFixed(2);
      const shippingMsg = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    
      return (
        <div>
          <p>{count}</p>
          <p>${total}</p>
          <p>{shippingMsg}</p>
          <button onClick={() => setCount(c => c + 1)}>Añadir</button>
          <button onClick={() => setCount(c => Math.max(0, c - 1))}>Quitar</button>
        </div>
      );
    }
    

    El bug del carrito es estructuralmente imposible aquí. total y shippingMsg se calculan en la misma función, a partir del mismo count, cada vez que el componente se ejecuta. No hay "actualizar" — hay "recalcular todo desde cero", así que no hay forma de que uno se sincronice y el otro se olvide.

    Ahí está la clave del Virtual DOM. React no toca el DOM real en cada cambio. Construye un árbol en memoria — objetos JavaScript planos que describen cómo debería verse la UI — y lo compara contra el árbol anterior. Ese proceso se llama reconciliation, y el algoritmo de comparación es el diffing: detecta qué nodos cambiaron, cuáles se reutilizan, y calcula el mínimo de operaciones para que el DOM real refleje el nuevo árbol. Solo entonces toca el DOM — y solo donde hace falta.

    Es un modelo declarativo y predecible. Pero el coste real no es gratis, y es lo que casi nadie menciona en los tutoriales de introducción: cada cambio de estado re-ejecuta la función completa del componente y, por defecto, la de sus hijos.

    En un árbol de cuarenta componentes anidados, un solo tecleo puede disparar cuarenta re-renders y cuarenta diffs — la mayoría comparando nodos que ni siquiera cambiaron.

    La respuesta del ecosistema fue la memoization: memo(), useMemo(), useCallback(). Son parches necesarios para un problema que el propio modelo introduce: no sabes qué cambió hasta que recalculas y comparas. Memoizar es responsabilidad manual otra vez — la misma que el Virtual DOM prometía eliminar, solo que movida un nivel más arriba en el árbol.

    Era 3: reactividad fina — el grafo en vez del árbol

    Los signals no comparan nada. No hay árbol virtual, no hay diffing, no hay re-render de una función completa. Un signal es una caja que guarda un valor y sabe, con precisión, quién depende de él.

    import { Component, signal, computed, effect } from '@angular/core';
    
    @Component({
      selector: 'app-cart-counter',
      template: `
        <p>{{ count() }}</p>
        <p>${{ total() }}</p>
        <p>{{ shippingMsg() }}</p>
        <button (click)="count.set(count() + 1)">Añadir</button>
        <button (click)="count.set(count() - 1)">Quitar</button>
      `,
    })
    export class CartCounterComponent {
      count = signal(0);
    
      total = computed(() => (this.count() * 19.99).toFixed(2));
    
      shippingMsg = computed(() =>
        this.count() >= 5
          ? '¡Envío gratis!'
          : `Añade ${5 - this.count()} más para envío gratis`
      );
    
      constructor() {
        effect(() => {
          console.log(`Carrito: ${this.count()} items — $${this.total()}`);
        });
      }
    }
    

    Cuando count cambia, Angular no re-ejecuta el componente entero ni reconstruye ningún árbol para comparar. total y shippingMsg ya saben que dependen de count — lo registraron la primera vez que se ejecutaron, al construirse el grafo reactivo. Angular actualiza exactamente el nodo del DOM ligado a cada binding. Nada más se mueve.

    Esto es reactividad fina (fine-grained reactivity): la granularidad de la actualización no es el componente, ni el subárbol — es el binding individual. Angular v22 lleva esto hasta el final siendo zoneless por defecto: ya no depende de Zone.js interceptando cada setTimeout o evento del navegador para saber cuándo revisar cambios. El grafo de signals es la única fuente de verdad sobre qué actualizar y cuándo.

    Angular no inventó este modelo — lo adoptó y lo llevó a producción a escala. Solid.js lo demostró primero, sin Virtual DOM desde el diseño inicial. Svelte llega a un resultado parecido compilando la reactividad en tiempo de build. Los tres coinciden en el mismo diagnóstico: comparar árboles es trabajo evitable si sabes de antemano quién depende de quién.

    Si quieres ver cada primitiva documentada en detalle, la guía oficial de Angular Signals cubre signal(), computed() y effect() con más profundidad de la que cabe en un post.

    Si quieres ver cómo se construye ese grafo de dependencias paso a paso — incluyendo los casos raros donde un effect() se dispara más veces de las que esperas — lo cubrí a fondo en el post sobre el grafo reactivo de Angular Signals.

    En el curso de Angular Moderno construimos este modelo mental desde cero, con proyectos reales donde pasar de Zone.js a zoneless cambia decisiones de arquitectura, no solo de sintaxis.

    Los tres paradigmas, uno al lado del otro

    Modelo mental Cómo detecta cambios Granularidad de la actualización Coste computacional Dónde brilla
    Callbacks (jQuery / DOM imperativo) Tú mutas el DOM a mano, evento por evento No detecta nada — el developer decide cuándo actualizar La que tú programes, elemento por elemento Bajo por operación, alto en mantenimiento y bugs de sincronización Widgets aislados, prototipos, páginas sin estado compartido
    Virtual DOM (React) La UI es una función pura del estado Diffing — compara árbol virtual anterior vs. nuevo Por componente/subárbol, tras re-ejecutar y comparar Re-ejecuta la función de render completa y diffea en cada cambio Apps con estado complejo, equipos grandes, ecosistema maduro
    Signals (Angular, Solid, Svelte) Grafo de dependencias reactivas Suscripción directa — el signal sabe quién lo consume El binding o nodo exacto del DOM que depende del valor Solo se ejecuta lo que realmente cambió UI de alta frecuencia de actualización, listas grandes, apps sensibles a rendimiento

    Por qué la reactividad fina no es una moda

    Cada era resolvió el cuello de botella real de la anterior — no la anterior en abstracto, la anterior en producción.

    Los callbacks resolvieron "cómo reacciono a un evento del usuario". Fue suficiente mientras la UI tenía poco estado compartido. Dejó de serlo en cuanto una sola acción tenía que actualizar cinco sitios distintos de la pantalla.

    El Virtual DOM resolvió "cómo mantengo la UI declarativa sin perder la cordura sincronizando elementos a mano". A cambio, aceptó un coste: recalcular y comparar árboles que, la mayoría de las veces, apenas habían cambiado.

    Signals resuelve el cuello de botella que el Virtual DOM introdujo: cómo evitar recalcular y comparar lo que ya sabías que no había cambiado. No es una versión "más rápida" de React. Es una respuesta distinta a la misma pregunta de fondo: ¿qué es lo mínimo que tengo que actualizar para que la UI diga la verdad?

    Esto no significa que el Virtual DOM esté acabado, ni que debas reescribir tu app de React mañana.

    Significa que si estás arrancando un proyecto hoy, entender este modelo ya no es opcional — es la diferencia entre construir sobre un patrón que resuelve el problema en su raíz o sobre uno que lo parchea con memoization.

    Esta decisión de arquitectura — dónde vive el estado, cómo fluye, qué parte del sistema es responsable de mantenerlo sincronizado con la UI — es exactamente el tipo de decisión que trato en el post sobre Clean Architecture para frontend con IA: la reactividad que elijas no es un detalle de implementación, es una decisión que carga con consecuencias durante años.

    Si vas a construir con signals en producción, en algún momento necesitarás verificar que esos computed() y effect() se comportan como esperas bajo distintos escenarios — eso es justo lo que trabajamos con casos reales en el curso de Testing en Angular con Jest y Testing Library.

    Y si quieres discutir esto con otros developers que están tomando las mismas decisiones ahora mismo, en Dominicode Labs es donde pasa esa conversación cada semana.

    Preguntas frecuentes sobre programación reactiva en el frontend

    ¿Qué es la programación reactiva?

    Es el paradigma en el que la interfaz se actualiza automáticamente cuando cambia el estado del que depende, sin que el desarrollador tenga que sincronizarla a mano evento por evento. Los tres modelos de este post — callbacks, Virtual DOM y signals — son formas distintas de resolver ese mismo problema, con más o menos reactividad real incorporada al framework.

    ¿El Virtual DOM está muerto?

    No. Sigue siendo el modelo dominante en producción — React tiene el ecosistema, el talento disponible y millones de líneas de código funcionando con él hoy. Lo que cambió es que ya no es la única opción seria para UI compleja: Signals, Solid.js y Svelte demuestran que el diffing es una solución al problema, no la única posible.

    ¿Los Signals reemplazan a React?

    No en el sentido de que React vaya a desaparecer. Angular con Signals, Solid.js y Svelte son alternativas con un modelo distinto, no reemplazos del ecosistema React. Sí es cierto que la presión competitiva ya empujó a React hacia herramientas como React Compiler, que intenta automatizar la memoization que antes hacías a mano.

    ¿Qué es la reactividad fina (fine-grained reactivity)?

    Es un modelo donde cada pieza de estado (signal) mantiene una lista explícita de quién depende de ella — otros signals derivados (computed) o efectos secundarios (effect). Cuando el valor cambia, solo se re-ejecuta lo que está suscrito a ese valor específico, sin comparar árboles ni recalcular lo que no depende de ese dato.

    ¿Angular usa Virtual DOM?

    No, y nunca lo usó. Angular usaba Zone.js y un mecanismo de change detection basado en recorrer el árbol de componentes buscando cambios. Con Signals y el modo zoneless, por defecto desde Angular v22, Angular elimina también ese recorrido: el grafo de signals le dice exactamente qué actualizar, sin Zone.js y sin diffing.

    ¿Debo migrar mi app de React a Signals?

    No si tu app funciona bien y el equipo domina React. La reactividad fina brilla en escenarios concretos: dashboards con actualizaciones muy frecuentes, listas grandes, apps donde el rendimiento de render es un cuello de botella medido, no sospechado. Si estás empezando un proyecto nuevo, sí vale la pena evaluar Angular v22 con Signals como opción seria.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Angular Signal Forms: por qué reemplaza a Reactive Forms

    Angular Signal Forms: por qué reemplaza a Reactive Forms

    Hace un par de semanas revisé un pull request de un formulario de checkout. Línea 40: const email = form.value.email as string. Le pregunté al autor por qué el cast. Silencio de dos segundos. Luego: "porque si no, TypeScript se queja".

    Ese "se queja" es el síntoma de un problema real que Angular Signal Forms existe justamente para eliminar. En Reactive Forms, form.value.email no es string. Es string | null. Angular lo tipa así porque reset() limpia los valores a null, y el compilador no tiene forma de saber si ya hiciste reset o no — así que cada lectura del formulario viene con un cast disfrazado de costumbre.

    Quince años escribiendo formularios en Angular. Quince años haciendo ese cast sin cuestionarlo. Angular Signal Forms, el nuevo sistema de formularios que llega con Angular v22, es la primera vez que veo una solución que no es un parche — es un cambio de premisa.


    Los 3 problemas reales de Reactive Forms

    Reactive Forms no es "malo" — lleva años en producción y funciona. Pero arrastra tres problemas estructurales que nacen de la misma raíz.

    1. El tipado es una mentira

    Por defecto, cada FormControl tiene tipo T | null, incluso cuando tu modelo de dominio nunca admite null en ese campo.

    form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
    });
    // form.value.email es string | null — no string
    

    El tipo del formulario nunca coincide con el tipo del dominio. Terminas casteando con as, o duplicando la interfaz a mano con un NonNullable<T> que mantienes sincronizado manualmente. Ninguna de las dos opciones es tipado real — son formas distintas de callar al compilador.

    2. El formulario es la fuente de verdad, no el modelo

    En Reactive Forms, el estado vive dentro del FormGroup. Tu modelo de dominio es una consecuencia que extraes cuando lo necesitas, no el origen de la verdad.

    Para extraerlo tienes cuatro caminos distintos, todos manuales: patchValue(), getRawValue(), reset() con un objeto de valores, o .get('email')?.setValue(x) campo por campo. Cuatro formas de sincronizar el mismo dato, cada una esperando a que se te olvide usarla en el momento correcto.

    3. ControlValueAccessor es la interfaz más odiada de Angular

    Cualquiera que haya construido un input custom en Angular conoce el ritual: implementar writeValue(), registerOnChange(), registerOnTouched(), setDisabledState(), y registrar un provider NG_VALUE_ACCESSOR en el componente.

    providers: [{
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomInputComponent),
      multi: true,
    }]
    

    Son cerca de 20 líneas de boilerplate imperativo por cada control. Sin genéricos reales, sin signals, sin forma de que el compilador te ayude si te equivocas en el tipo del valor que emites.


    Signal Forms: el reinicio conceptual

    Aquí está el punto que más se malinterpreta: Signal Forms no es "Reactive Forms 2". No es una versión mejorada del mismo paradigma con signals encima. Es empezar de cero con una premisa distinta.

    En Reactive Forms, el formulario manda y el modelo es una extracción. En Signal Forms, el modelo es un signal() normal y el formulario es una vista reactiva de ese signal. Cuando el usuario escribe en un input, el signal se actualiza. Cuando tú cambias el signal desde código, el formulario se actualiza solo. Ya no hay cuatro formas de sincronizar — solo tocas el signal.

    Es el mismo giro conceptual que ya vimos con la reactividad fina de signals frente a callbacks y suscripciones manuales: una fuente de verdad única, y todo lo demás reacciona a ella. Signal Forms aplica esa misma idea al dominio de los formularios.

    @Component({
      imports: [FormField, FormRoot],
      template: `
        <form [formRoot]="loginForm">
          <input [formField]="loginForm.email" type="email" />
          @if (loginForm.email().invalid() && loginForm.email().touched()) {
            <span>Email inválido</span>
          }
          <input [formField]="loginForm.password" type="password" />
          <button type="submit">Entrar</button>
        </form>
      `
    })
    export class LoginComponent {
      readonly loginModel = signal({ email: '', password: '' });
      readonly loginForm = form(this.loginModel, loginSchema);
    }
    

    Compara esto con el FormGroup de arriba. No hay fb.group(), no hay Validators.email como clase estática, no hay .get('email'). Hay un signal (loginModel) y una función (form()) que lo envuelve.

    form(), FieldTree y FieldState

    form(model, schema?) recibe tu modelo — y aquí hay una regla estricta: el modelo debe ser un WritableSignal<T>, un signal() normal y escribible. Si le pasas un computed(), no compila. No es un "formulario de solo lectura" — es directamente un error de tipos, porque Signal Forms necesita poder escribir de vuelta en el modelo cuando el usuario interactúa.

    form() devuelve un FieldTree<T>. Accedes a cada campo por dot-notation: loginForm.email es un nodo de ese árbol. Al invocarlo como función — loginForm.email() — obtienes un FieldState, que expone todo como signals: value() (un WritableSignal), dirty(), touched(), invalid(), valid(), errors(), pending(), disabled(), readonly(), required(), hidden(). Y métodos como markAsTouched(), markAsDirty(), reset(). No existe ningún tipo llamado "FieldNode" — es FieldTree para la estructura y FieldState para el estado de un campo concreto.

    Sin módulos, solo directivas standalone

    No existe ningún FormsSignalsModule. Las dos piezas que necesitas son directivas standalone: FormField (aporta [formField], va en cada input) y FormRoot (aporta [formRoot], va en el <form>). Se importan una por una: imports: [FormField, FormRoot].

    Signal Forms vive en @angular/forms/signals, ya incluido si tienes @angular/forms en v21 o superior. No necesitas ningún provider global adicional en app.config.ts — es standalone de principio a fin.

    [formRoot] es la pieza nueva de v21.2/v22 que más simplifica el día a día: aplica novalidate automáticamente, intercepta el submit nativo, hace preventDefault y dispara el envío del FieldTree sin que tengas que escribir un (ngSubmit) manual. El estilo clásico de <form> + (submit) sigue funcionando si lo prefieres, pero el camino recomendado en Signal Forms es el declarativo con [formRoot].

    Esto es exactamente el tipo de arquitectura que estamos actualizando a v22 en el curso de Angular Moderno — el objetivo no es memorizar la API nueva, sino entender por qué el modelo manda ahora en vez del framework.


    Validación con schema declarativo

    La validación se declara aparte, con schema<T>((path) => { ... }). La convención oficial nombra el parámetro path — no f, no form, no otro nombre. Vale la pena respetarla porque es lo que vas a ver en toda la documentación y en el código de otros equipos.

    const loginSchema = schema<LoginForm>((path) => {
      required(path.email);
      email(path.email);
      required(path.password);
      minLength(path.password, 8);
    });
    

    Los validadores built-in siguen todos el mismo patrón, con el path primero: required(path.campo), email(path.campo), minLength(path.campo, n), maxLength(path.campo, n), min(path.campo, n), max(path.campo, n), pattern(path.campo, regex).

    Si escribes un validador custom, hay una regla que no es opcional: debe devolver undefined en caso de éxito, nunca null. Es la "regla del undefined" — un detalle pequeño que rompe la validación entera si lo pasas por alto viniendo de la costumbre de Reactive Forms, donde null era la señal de "todo bien".


    Controles custom sin ControlValueAccessor

    Los controles custom en Signal Forms se implementan con la interfaz FormValueControl<T>, que reemplaza a ControlValueAccessor con un solo miembro obligatorio: value, como ModelSignal<T> creado con model(). Todo lo demás — disabled, errors, touched, required — son input() opcionales.

    Es aquí donde Signal Forms cambia más el día a día si construyes design systems o librerías de componentes internas. ControlValueAccessor exige implementar cuatro métodos y registrar un provider, como vimos arriba.

    export class CustomInputComponent implements FormValueControl<string> {
      readonly value = model('');                      // único miembro REQUERIDO
      readonly disabled = input(false);                 // opcional
      readonly errors = input<ValidationError[]>([]);   // opcional
    }
    

    Eso es todo. Sin forwardRef, sin NG_VALUE_ACCESSOR, sin registerOnChange guardando una función en una propiedad privada. El compilador conoce el tipo real del valor porque model<string>() lo declara, no porque tú lo prometas en un comentario.


    El estado real de madurez en v22 — sin exagerar

    Signal Forms es @experimental desde Angular v21. En v22, la API core madura significativamente — el comportamiento de form(), [formField], schema() y los validadores built-in se estabiliza. Pero el marcado @experimental puede seguir presente en algunos subconjuntos de la API. Funciones más avanzadas, como validateAsync() o compatForm(), pueden tener cambios menores antes de la estabilización oficial completa.

    Este es exactamente el tipo de dato que el ecosistema Angular audita de cerca, así que voy a ser preciso: "ya es estable, úsalo sin pensar" sería una simplificación que no te sirve para decidir en producción. Puedes verificar el estado exacto de cada API en la documentación oficial de formularios de Angular.

    En la práctica, esto se traduce en tres escenarios:

    Escenario Recomendación
    Código nuevo en tu proyecto propio o side project Úsalo sin reservas
    Código nuevo en un proyecto de empresa Evalúa el riesgo, documenta la decisión con el equipo
    Migrar formularios legacy críticos Espera a la estabilización completa

    Si decides adoptarlo ahora, blindarlo con tests importa más que nunca — y el cambio de paradigma en testing es tan grande como en los formularios mismos. Testear un FieldTree no es testear un FormGroup: lees el value() directamente como signal, sin suscribirte a valueChanges ni esperar un ciclo extra de detección de cambios para que el observable propague. Es exactamente el tipo de ajuste que cubrimos en el curso de Testing en Angular actualizado a este modelo.


    Reactive Forms vs Signal Forms — comparativa

    Reactive Forms Signal Forms
    Tipado T | null por defecto, casts frecuentes El tipo del modelo es el tipo real, sin null fantasma
    Fuente de verdad El FormGroup — el modelo se extrae El signal() del modelo — el form es una vista
    Controles custom ControlValueAccessor, ~20 líneas, sin genéricos FormValueControl<T>, solo value requerido
    Boilerplate Alto: provider, 4 métodos, forwardRef Bajo: directivas standalone, sin módulo
    Estado de madurez (v22) Estable, años en producción @experimental, API core estable en comportamiento

    La tesis

    Signal Forms no es una feature más de la lista de novedades de v22. Es la misma premisa que ya cambió cómo pensamos la reactividad con signal(), computed() y effect(), aplicada ahora al dominio de los formularios: el modelo manda, el framework refleja.

    Angular lleva desde v16 moviéndose hacia signals-first, y Signal Forms es la pieza que faltaba para que esa filosofía cubriera también la parte más tediosa de cualquier aplicación real. Si quieres ver dónde encaja dentro del resto de cambios de esta versión, lo cubrimos en el repaso de novedades de Angular v22.

    Lo que puedes hacer hoy: si tienes un side project o un proyecto nuevo sin presión de legacy, monta el próximo formulario con Signal Forms. No esperes a que el @experimental desaparezca del todo — la API core ya se comporta como se va a comportar. Si estás en un proyecto de empresa, documenta la decisión y evalúa el riesgo con tu equipo antes de migrar nada crítico.

    En Dominicode Labs estamos ya construyendo con Signal Forms en los proyectos de la comunidad — si quieres ver los patrones reales, sin el filtro del ejemplo de documentación, es ahí donde está pasando.


    Preguntas frecuentes sobre Angular Signal Forms

    ¿Qué es Angular Signal Forms?

    Es el nuevo sistema de formularios de Angular, disponible desde v21 como @experimental y madurando en v22. En vez de que el formulario sea la fuente de verdad, el modelo es un signal() normal y el formulario es una vista reactiva de ese signal. Se construye con form(model, schema), que devuelve un FieldTree, y se conecta al template con las directivas standalone [formField] y [formRoot].

    ¿Signal Forms reemplaza a Reactive Forms?

    Conceptualmente sí, pero no de un día para otro. Reactive Forms sigue siendo estable y soportado. Signal Forms no es "Reactive Forms con signals encima" — es un sistema construido desde cero sobre la premisa de que el modelo manda. Para proyectos nuevos, es la dirección a seguir. Para formularios existentes en producción, migrarlos no es urgente todavía.

    ¿Puedo usar Signal Forms en producción en Angular 22?

    Depende del contexto. En tu proyecto propio, sí, sin reservas — la API core (form(), [formField], schema(), validadores built-in) se estabiliza en comportamiento en v22. En un proyecto de empresa, evalúa el riesgo y documenta la decisión, porque el marcado @experimental puede seguir en algunos subconjuntos de la API. Para migrar formularios legacy críticos, espera a la estabilización oficial completa.

    ¿Necesito importar un módulo para usar Signal Forms?

    No. No existe ningún FormsSignalsModule. Las directivas FormField y FormRoot son standalone y se importan directamente en el array imports del componente. Tampoco necesitas ningún provider adicional en app.config.ts — solo tener @angular/forms en v21 o superior.

    ¿Cómo se hacen controles de formulario custom en Signal Forms?

    Con la interfaz FormValueControl<T>, que reemplaza a ControlValueAccessor. Solo necesitas un miembro obligatorio: value como ModelSignal<T> creado con model(). Propiedades como disabled, errors, touched o required son input() opcionales, sin necesidad de provider ni forwardRef.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.