Módulo 13: Resources: datos asíncronos con señales

Objetivos del módulo

  • Entender qué problemas resuelve un recurso frente a cargar datos a mano con subscribe
  • Crear recursos con resource, y distinguir params de loader
  • Cancelar cargas obsoletas con abortSignal y evitar las condiciones de carrera
  • Recorrer los seis estados de un recurso y saber qué devuelve value() en cada uno
  • Pedir datos a la API EMPRESA con httpResource: URL, peticiones completas, parse, cabeceras y código de estado
  • Usar rxResource cuando la carga es un Observable
  • Encadenar recursos con ResourceParamsContext y propagar estados y errores
  • Recibir valores parciales con stream
  • Escribir en un recurso con set y update, y hacer actualizaciones optimistas con deshacer
  • Componer recursos con snapshot, conservar el valor durante la carga y retrasar búsquedas
  • Mostrar carga, error y datos en plantillas sin romper la vista
  • Usar recursos en servicios, entender su ciclo de vida y probarlos
  • Elegir entre resource, httpResource, rxResource y HttpClient

🔄 Cambia en Angular 22.0. resource, httpResource y rxResource son estables desde esta versión. En la LTS (Angular 21) existen, pero son experimentales y pueden cambiar sin previo aviso. Si usas la LTS: casi todo este módulo funciona igual; los apartados que cambian llevan su propia marca.


1. El problema: cargar datos a mano

En el módulo 12 pediste datos con HttpClient. Mostrar un solo empleado en pantalla, con su botón para cambiar de persona, exige bastante más código del que parece, porque hay que representar tres situaciones (cargando, error y datos) y mantenerlas coherentes:

// src/app/a-mano/a-mano.ts
import { HttpClient } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { Subscription } from 'rxjs';
import { API } from '../api';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-a-mano',
  template: `
    <button type="button" (click)="cargar(100)">Ana</button>
    <button type="button" (click)="cargar(101)">Luis</button>

    @if (cargando()) {
      <p>Cargando...</p>
    } @else if (error()) {
      <p>{{ error() }}</p>
    } @else if (empleado(); as e) {
      <h2>{{ e.nombre }} {{ e.apellidos }}</h2>
    }
  `,
})
export class AMano {
  private readonly http = inject(HttpClient);
  private peticion?: Subscription;

  protected readonly empleado = signal<Empleado | undefined>(undefined);
  protected readonly cargando = signal(false);
  protected readonly error = signal<string | undefined>(undefined);

  protected cargar(id: number) {
    this.peticion?.unsubscribe(); // sin esta línea hay condición de carrera
    this.cargando.set(true);
    this.error.set(undefined);
    this.empleado.set(undefined);

    this.peticion = this.http.get<Empleado>(`${API}/empleados/${id}`).subscribe({
      next: (e) => {
        this.empleado.set(e);
        this.cargando.set(false);
      },
      error: () => {
        this.error.set('No se pudo cargar el empleado');
        this.cargando.set(false);
      },
    });
  }
}

Fíjate en todo lo que has tenido que recordar:

  • Tres señales que van juntas. Nada impide tener cargando a true y un error a la vez. Cada rama de cargar debe dejarlas bien.
  • Cancelar a mano. Si olvidas unsubscribe, aparece el fallo de la figura siguiente.
  • Disparo imperativo. Alguien tiene que llamar a cargar. Si el identificador llega por un input(), necesitas además un effect.
  • Limpieza al destruir. Si el componente desaparece con una petición en vuelo, también hay que cancelarla.

Dos líneas de tiempo: sin cancelar, la respuesta lenta del empleado 100 llega después y pisa a la del 101; con resource, la carga del 100 se aborta y solo se pinta el 101

Figura 1. Una condición de carrera: la pantalla acaba mostrando el dato de la petición antigua, porque era la más lenta.

Una condición de carrera ocurre cuando el resultado depende de qué tarea asíncrona termina antes. El usuario pulsa «Ana» y luego «Luis», pero la red no garantiza el orden de las respuestas. Si la de Ana llega la última, la pantalla dice Ana mientras el botón activo dice Luis. Es un error difícil de reproducir, porque en tu ordenador la API responde siempre rápido.

📘 Concepto: Un recurso (resource) es un valor asíncrono expuesto como señales. Le dices de qué datos depende y cómo se cargan, y él gestiona el resto: lanza la carga cuando cambian esos datos, cancela la anterior y publica estado, valor y error de forma coherente.

Es la pieza que une el mundo síncrono de las señales, que viste en el módulo 05, con el mundo asíncrono de la red. Una señal siempre tiene un valor ahora; un computed no puede esperar a una promesa. Un recurso sí puede.


2. resource: params y loader 🔄 v22.0

🔄 Cambia en Angular 22.0. resource pasa de experimental a estable. Si usas la LTS: se usa igual, importado también desde @angular/core, pero al ser experimental puede cambiar entre versiones; revisa las notas de cada actualización.

Esta es la misma pantalla que antes, ahora con resource:

// src/app/detalle-empleado/detalle-empleado.ts
import { Component, resource, signal } from '@angular/core';
import { API } from '../api';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-detalle-empleado',
  template: `
    <nav>
      @for (id of ids; track id) {
        <button type="button" [class.activo]="id === empId()" (click)="empId.set(id)">
          {{ id }}
        </button>
      }
    </nav>

    @if (empleado.isLoading()) {
      <p class="aviso">Cargando empleado...</p>
    } @else if (empleado.error(); as error) {
      <p class="error">{{ error.message }}</p>
      <button type="button" (click)="empleado.reload()">Reintentar</button>
    } @else if (empleado.hasValue()) {
      <article class="ficha">
        <h2>{{ empleado.value().nombre }} {{ empleado.value().apellidos }}</h2>
        <p>{{ empleado.value().oficio }} en {{ empleado.value().departamento }}</p>
      </article>
    }
  `,
})
export class DetalleEmpleado {
  protected readonly ids = [100, 101, 102, 9999];
  protected readonly empId = signal(100);

  protected readonly empleado = resource({
    params: () => this.empId(),
    loader: async ({ params, abortSignal }) => {
      const respuesta = await fetch(`${API}/empleados/${params}`, { signal: abortSignal });
      if (!respuesta.ok) {
        throw new Error(`No se pudo cargar el empleado ${params} (HTTP ${respuesta.status})`);
      }
      return (await respuesta.json()) as Empleado;
    },
  });
}

Solo hay una señal de entrada, empId, y el recurso empleado. Ya no existe ningún método cargar: pulsar un botón cambia la señal, y lo demás ocurre solo.

Ficha con los botones 100, 101, 102 y 9999 y el aviso Cargando empleado

Figura 2. Durante la primera carga, isLoading() es true.

Los mismos botones con un mensaje de error en rojo para el empleado 9999 y un botón Reintentar

Figura 3. El empleado 9999 no existe: el throw del loader deja el recurso en estado de error.

resource recibe un objeto con dos funciones que hacen trabajos muy distintos:

Función Cuándo se ejecuta Qué hace ¿Reactiva?
params Al crearse y cada vez que cambia una señal leída dentro Calcula de qué depende la carga Sí, como un computed
loader Cuando params da un valor nuevo o se recarga Hace el trabajo asíncrono y devuelve una promesa No

Las señales alimentan params, params lanza el loader, el loader rellena las señales de salida y estas la plantilla; abortSignal cancela y undefined deja el recurso inactivo

Figura 4. Recorrido de un recurso. La reactividad termina en params.

⚠️ Importante: Lo que leas dentro del loader no se vigila. Si haces const p = this.pagina() dentro del loader, cambiar pagina no lanzará otra carga. En una prueba, un loader que leía una señal se ejecutó una sola vez aunque esa señal cambió después. Toda dependencia debe pasar por params.

El parámetro del loader

El loader recibe un objeto ResourceLoaderParams:

Propiedad Contenido
params Lo que devolvió params(). El tipo excluye undefined, porque en ese caso el loader no se llama
abortSignal Un AbortSignal que se activa si esta carga deja de interesar
previous.status Estado del recurso cuando empezó esta carga

abortSignal es el objeto estándar del navegador que ya admite fetch. Cuando el usuario cambia de empleado antes de que termine la carga, Angular lo aborta, y fetch cancela la petición de verdad. Se comprobó pulsando 100, 101 y 102 seguidos con una API lenta, y el navegador informó de lo siguiente:

/empleados/100 net::ERR_ABORTED
/empleados/101 net::ERR_ABORTED

Solo la petición del 102 llegó a la pantalla. Aunque no uses abortSignal, Angular ignora el resultado de una carga obsoleta, así que no habrá carrera. Pero la petición seguiría gastando red y servidor: pásalo siempre.

⚠️ Importante: fetch no lanza una excepción con un 404 o un 500; solo falla si no hay red. Por eso el ejemplo comprueba respuesta.ok y lanza su propio error. Sin esa comprobación, el JSON del error se tomaría por un empleado. httpResource hace esta comprobación por ti.

Todas las opciones

Opción Tipo Para qué sirve
params (ctx) => R Dependencias. Si falta, el loader se ejecuta una vez y solo vuelve a hacerlo con reload()
loader (p) => PromiseLike<T> Carga que devuelve una promesa
stream (p) => ... Carga con varios valores (sección 8). Excluye loader
defaultValue T Valor cuando no hay datos
equal (a, b) => boolean Decide si el valor nuevo es igual que el anterior
injector Injector Permite crear el recurso fuera de un contexto de inyección
debugName string Nombre para Angular DevTools

Cuándo se repite la carga 🔄 v22.2

🔄 Cambia en Angular 22.2. Si params se recalcula y da el mismo valor, el recurso no vuelve a cargar (corrección incluida en 22.2.4). En la LTS (Angular 21) sí recarga, porque basta con que cambie una señal leída en params. Si usas la LTS: calcula el parámetro en un computed aparte y pásalo como params: el computed no avisa si su resultado no cambia.

Imagina este recurso, que depende de una parte de una señal:

protected readonly numero = signal(1);

protected readonly datos = resource({
  params: () => this.numero() % 2,
  loader: async ({ params }) => cargarDatos(params),
});

Si numero pasa de 1 a 3, el resultado de params sigue siendo 1. Con 22.2.4 el loader se ejecuta una sola vez; con 21.2 se ejecuta dos. Un computed aparte funciona igual en las dos versiones, porque params deja de recalcularse:

private readonly paridad = computed(() => this.numero() % 2);

protected readonly datos = resource({
  params: this.paridad,
  loader: async ({ params }) => cargarDatos(params),
});

💡 Consejo: Si params devuelve un objeto, cada cálculo produce uno nuevo, y por tanto una carga nueva aunque el contenido coincida. Se midió en las dos versiones: dos cargas. Si eso te molesta, devuelve un valor primitivo o pasa el objeto por un computed con la opción equal.

Excepciones en params 🔄 v22.0

🔄 Cambia en Angular 22.0. Si params lanza una excepción, el recurso pasa a error y puedes tratarla en la plantilla. En la LTS (Angular 21) la excepción no se captura en el recurso y llega como error sin tratar. Si usas la LTS: evita lanzar en params; comprueba los datos y devuelve undefined si no son válidos.


3. Los estados de un recurso

Un recurso expone su situación con varias señales que siempre son coherentes entre sí:

Miembro Devuelve
status() Uno de los seis estados
value() El valor actual. Lanza una excepción si el estado es error
error() El error, o undefined
isLoading() true en loading y reloading
hasValue() true si hay un valor distinto de undefined y no hay error
snapshot() Estado y valor en un solo objeto (sección 10)

El tipo ResourceStatus tiene seis valores:

Estado Significado value()
idle No hay parámetros: nada que cargar defaultValue o undefined
loading Primera carga, o carga con parámetros nuevos defaultValue o undefined
reloading Recarga con los mismos parámetros El valor anterior
resolved La carga ha terminado bien El valor cargado
local Alguien escribió el valor con set o update El valor escrito
error La carga ha fallado Lanza una excepción

Diagrama de estados: idle pasa a loading; loading a resolved o error; resolved a local con set; resolved y local a reloading con reload; error vuelve con reload o params nuevos

Figura 5. Estados de un recurso y cómo se pasa de uno a otro.

La mejor forma de entenderlos es verlos. Este componente anota en pantalla cada cambio de estado:

// src/app/traza-estados/traza-estados.ts
import { Component, effect, resource, signal, untracked } from '@angular/core';
import { API } from '../api';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-traza-estados',
  template: `
    <div class="botones">
      <button type="button" id="c100" (click)="empId.set(100)">Cargar 100</button>
      <button type="button" id="c101" (click)="empId.set(101)">Cargar 101</button>
      <button type="button" id="recargar" (click)="empleado.reload()">Recargar</button>
      <button type="button" id="local" (click)="renombrar()">Editar en local</button>
      <button type="button" id="c9999" (click)="empId.set(9999)">Cargar 9999</button>
      <button type="button" id="vaciar" (click)="empId.set(undefined)">Sin id</button>
    </div>
    <pre class="traza">{{ lineas().join('\n') }}</pre>
  `,
})
export class TrazaEstados {
  protected readonly empId = signal<number | undefined>(undefined);
  protected readonly lineas = signal<string[]>([]);

  protected readonly empleado = resource({
    params: () => this.empId(),
    loader: async ({ params, abortSignal, previous }) => {
      this.anotar(`   loader(${params}) previous.status=${previous.status}`);
      const respuesta = await fetch(`${API}/empleados/${params}`, { signal: abortSignal });
      if (!respuesta.ok) {
        throw new Error(`HTTP ${respuesta.status}`);
      }
      return (await respuesta.json()) as Empleado;
    },
  });

  constructor() {
    effect(() => {
      const estado = this.empleado.status();
      const cargando = this.empleado.isLoading();
      const hayValor = this.empleado.hasValue();
      const valor = hayValor ? this.empleado.value()?.nombre : '(no leído)';
      const error = this.empleado.error()?.message ?? '-';
      untracked(() =>
        this.anotar(
          `${estado.padEnd(9)} isLoading=${String(cargando).padEnd(5)} ` +
            `hasValue=${String(hayValor).padEnd(5)} valor=${valor} error=${error}`,
        ),
      );
    });
  }

  protected renombrar() {
    this.empleado.update((e) => (e ? { ...e, nombre: 'Editado' } : e));
  }

  private anotar(linea: string) {
    this.lineas.update((lista) => [...lista, linea]);
  }
}

Pulsando los botones en orden, de izquierda a derecha, y esperando a que acabe cada carga, se obtiene esta salida real:

idle      isLoading=false hasValue=false valor=(no leído) error=-
   loader(100) previous.status=idle
loading   isLoading=true  hasValue=false valor=(no leído) error=-
resolved  isLoading=false hasValue=true  valor=Ana error=-
   loader(101) previous.status=resolved
loading   isLoading=true  hasValue=false valor=(no leído) error=-
resolved  isLoading=false hasValue=true  valor=Luis error=-
   loader(101) previous.status=resolved
reloading isLoading=true  hasValue=true  valor=Luis error=-
resolved  isLoading=false hasValue=true  valor=Luis error=-
local     isLoading=false hasValue=true  valor=Editado error=-
   loader(9999) previous.status=local
loading   isLoading=true  hasValue=false valor=(no leído) error=-
error     isLoading=false hasValue=false valor=(no leído) error=HTTP 404
idle      isLoading=false hasValue=false valor=(no leído) error=-

La pantalla del componente de traza con seis botones y las líneas de estado en un panel oscuro

Figura 6. La traza en el navegador. Aparece loader(...) antes que loading porque los effect se ejecutan después, en la siguiente pasada.

Lo que debes observar:

  • loading borra el valor. Al pasar de 100 a 101, hasValue es false: no se muestra a Ana mientras carga Luis, porque serían datos de otra persona.
  • reloading lo conserva. Con los mismos parámetros, Luis sigue en pantalla mientras se refresca.
  • previous.status indica desde qué estado se lanzó la carga, por ejemplo para decidir si merece la pena usar una caché.

value() lanza una excepción en estado de error

Este es el fallo más habitual con recursos. Si una plantilla lee value() sin comprobar nada y la carga falla, ocurre esto en la consola:

ERROR ResourceValueError: Resource is currently in an error state (see Error.cause for details): ...

La excepción se produce dentro de la actualización de la vista, así que el componente deja de actualizarse. En la prueba, tras el error, un botón que escribía en una señal ya no cambiaba nada en pantalla. También computed hereda el problema: uno que lea value() lanza la misma excepción al leerlo.

⚠️ Importante: Antes de leer value(), comprueba hasValue() o error(). Ni siquiera defaultValue te protege: el valor por defecto sirve en idle y loading, pero en error value() lanza igualmente.

La causa real está en dos sitios: error() la da directamente, y la excepción la guarda en cause. Si el loader lanza algo que no es un Error, como throw 'texto', Angular lo envuelve en un ResourceWrappedError con el original en cause, de modo que error() siempre devuelve un Error.

hasValue() y defaultValue

hasValue() hace dos trabajos. En ejecución, evita la excepción: si hay error devuelve false sin lanzar. Y para TypeScript, estrecha el tipo: tras @if (empleado.hasValue()), empleado.value() ya no incluye undefined.

Sin defaultValue, el tipo del recurso es Empleado | undefined. Con él, es Empleado, y puedes leer value().contenido sin comprobaciones, salvo por el caso del error:

protected readonly departamentos = resource({
  defaultValue: [] as Departamento[],
  loader: () => cargarDepartamentos(),
});

💡 Consejo: Con defaultValue, hasValue() da true también mientras carga, porque el valor por defecto no es undefined. No lo uses para saber si «ya han llegado los datos»: para eso está status().


4. Recargar y dejar inactivo

reload()

reload() repite la carga con los mismos parámetros, y es la base de los botones «Actualizar» y «Reintentar». Devuelve true si ha iniciado una recarga y false si no tenía sentido:

Estado al llamar Resultado Estado siguiente
resolved o local true reloading, conserva el valor
error true reloading, ver el aviso
loading o reloading false Sigue la carga en curso
idle false Nada que cargar

⚠️ Importante: Al reintentar tras un fallo, el estado es reloading, pero error() sigue devolviendo el error antiguo hasta que termina la carga, y value() da defaultValue o undefined. Si tu plantilla pregunta primero por error(), el usuario pulsará «Reintentar» y no verá nada. Pregunta primero por isLoading(), como en DetalleEmpleado.

idle: no cargar todavía

Si params devuelve undefined, el recurso queda en idle y el loader no se llama. Es la forma de expresar «aún no sé qué cargar», típica de los paneles de detalle sin selección:

protected readonly seleccion = signal<number | undefined>(undefined);

protected readonly detalle = resource({
  params: () => this.seleccion(), // undefined: inactivo
  loader: ({ params }) => cargarEmpleado(params), // aquí params es number
});

⚠️ Importante: Solo undefined deja inactivo el recurso. Un null es un parámetro válido y lanzaría el loader. Si tu señal usa null, escribe params: () => this.seleccion() ?? undefined.

destroy()

Un recurso se destruye solo cuando se destruye su contexto, normalmente el componente, y entonces cancela lo que tuviera en marcha. destroy() hace lo mismo a mano: cancela y deja el recurso en idle para siempre. Solo lo necesitarás con recursos que creas tú en tiempo de ejecución (sección 12).


5. httpResource: recursos sobre HttpClient 🔄 v22.0

🔄 Cambia en Angular 22.0. httpResource pasa de experimental a estable. Si usas la LTS: se usa igual, importado desde @angular/common/http, pero es experimental. Por debajo usa XMLHttpRequest en lugar de fetch, salvo que configures provideHttpClient(withFetch()).

Casi todos los recursos de una aplicación piden JSON a una API, y escribir el loader con fetch cada vez sería tedioso. httpResource es esa combinación ya hecha: solo le dices qué URL quieres.

protected readonly empleado = httpResource<Empleado>(() => `${API}/empleados/${this.empId()}`);

Esa función hace de params: se reevalúa cuando cambia empId y lanza una petición nueva, cancelando la anterior. Como funciona sobre HttpClient, tienes todo lo del módulo 12: interceptores, cabecera de autenticación y provideHttpClientTesting. Y lo devuelto es un recurso completo, con los mismos estados.

📘 Concepto: A diferencia de http.get, que no hace nada hasta que alguien se suscribe, httpResource lanza la petición por sí solo, en cuanto el recurso se crea. Es adecuado para leer, no para escribir.

httpResource<Empleado> no comprueba nada en ejecución: igual que http.get<Empleado>, solo le dice a TypeScript qué esperar. Para validar de verdad está parse, que verás enseguida.

Una lista paginada contra EMPRESA

En este ejemplo la función devuelve un objeto HttpResourceRequest en lugar de una cadena. Los parámetros van en params, y Angular se encarga de codificarlos:

// src/app/lista-empleados/lista-empleados.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, linkedSignal, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

const PAGINA_VACIA: Pagina<Empleado> = {
  contenido: [],
  pagina: 1,
  tamano: 5,
  total: 0,
  paginas: 1,
};

@Component({
  selector: 'app-lista-empleados',
  template: `
    <div class="filtros">
      <input
        type="search"
        placeholder="Buscar..."
        [value]="busqueda()"
        (input)="busqueda.set($any($event.target).value)"
      />
      <select (change)="orden.set($any($event.target).value)">
        <option value="apellidos">Por apellidos</option>
        <option value="-salario">Mayor salario</option>
      </select>
      <span class="estado">{{ empleados.status() }}</span>
    </div>

    @if (empleados.error(); as error) {
      <p class="error">{{ error.message }}</p>
      <button type="button" (click)="empleados.reload()">Reintentar</button>
    } @else {
      <table [class.apagada]="empleados.isLoading()">
        <thead>
          <tr><th>Nombre</th><th>Oficio</th><th>Salario</th></tr>
        </thead>
        <tbody>
          @for (e of empleados.value().contenido; track e.empId) {
            <tr>
              <td>{{ e.apellidos }}, {{ e.nombre }}</td>
              <td>{{ e.oficio }}</td>
              <td class="num">{{ e.salario }} €</td>
            </tr>
          } @empty {
            <tr><td colspan="3">Sin resultados</td></tr>
          }
        </tbody>
      </table>

      <div class="paginador">
        <button type="button" [disabled]="numeroPagina() === 1" (click)="mover(-1)">Anterior</button>
        <span>{{ resumen() }}</span>
        <button type="button" id="siguiente" [disabled]="numeroPagina() >= empleados.value().paginas" (click)="mover(1)">
          Siguiente
        </button>
      </div>
    }
  `,
})
export class ListaEmpleados {
  protected readonly busqueda = signal('');
  protected readonly orden = signal('apellidos');

  // Vuelve a la página 1 cuando cambia la búsqueda
  protected readonly numeroPagina = linkedSignal({
    source: this.busqueda,
    computation: () => 1,
  });

  protected readonly empleados = httpResource<Pagina<Empleado>>(
    () => ({
      url: `${API}/empleados`,
      params: {
        q: this.busqueda(),
        orden: this.orden(),
        pagina: this.numeroPagina(),
        tamano: 5,
      },
    }),
    { defaultValue: PAGINA_VACIA },
  );

  protected readonly resumen = computed(() => {
    const { pagina, paginas, total } = this.empleados.value();
    return `Página ${pagina} de ${paginas} (${total} empleados)`;
  });

  protected mover(delta: number) {
    this.numeroPagina.update((n) => n + delta);
  }
}

Tabla de cinco empleados con buscador, selector de orden, paginador y la etiqueta resolved

Figura 7. La lista contra la API EMPRESA. Buscar, ordenar y paginar son solo cambios de señales.

No hay ningún método que «cargue» la lista. Cada control escribe en una señal, y la función del recurso combina las señales en una petición. $any(...) solo evita la comprobación de tipos para leer value de $event.target. El linkedSignal de numeroPagina (visto en el módulo 05) vuelve a la página 1 cada vez que cambia la búsqueda.

Sin embargo, tiene un defecto. Mira qué ocurre al pulsar «Siguiente» con una API lenta:

La misma tabla mientras carga: indica Sin resultados y Página 1 de 1 con 0 empleados

Figura 8. Cambiar de página es loading, no reloading: el valor vuelve a defaultValue y la tabla parpadea.

Durante la carga la pantalla dice «Sin resultados», y el paginador miente. Es el comportamiento documentado: con parámetros nuevos, el valor anterior deja de ser válido. En la sección 10 lo arreglarás conservando el valor antiguo.

Campos de HttpResourceRequest

Campo Para qué sirve
url La URL, sin parámetros de consulta
method GET por defecto
params Parámetros de consulta: HttpParams o un objeto
headers Cabeceras: HttpHeaders o un objeto
body Cuerpo de la petición
context HttpContext, para pasar datos a los interceptores
timeout Tiempo máximo en milisegundos
reportProgress Publica el progreso en la señal progress()
withCredentials, credentials Envío de cookies
cache, priority, mode, redirect, keepalive, referrer, referrerPolicy, integrity Opciones de fetch
transferCache Caché de transferencia en SSR, que verás en el módulo 24

Como con resource, si la función devuelve undefined no se hace la petición y el recurso queda en idle.

parse, cabeceras y código de estado

httpResource añade tres señales a las de cualquier recurso: statusCode(), headers() y progress(). Además, admite parse, una función que recibe la respuesta sin tipo y devuelve lo que quieras. Sirve para validar la respuesta y para transformarla, y el tipo del recurso se deduce de lo que devuelva:

// src/app/respuesta-http/respuesta-http.ts
import { httpResource, HttpErrorResponse } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { API } from '../api';
import { Departamento } from '../modelos';

// Convierte el JSON sin tipo en un objeto en el que podemos confiar
function aDepartamento(crudo: unknown): Departamento & { etiqueta: string } {
  const d = crudo as Departamento;
  if (typeof d?.deptId !== 'number' || typeof d?.nombre !== 'string') {
    throw new Error('La respuesta no parece un departamento');
  }
  return { ...d, etiqueta: `${d.nombre} (${d.localidad})` };
}

@Component({
  selector: 'app-respuesta-http',
  template: `
    <div class="botones">
      <button type="button" id="d10" (click)="deptId.set(10)">Departamento 10</button>
      <button type="button" id="d30" (click)="deptId.set(30)">Departamento 30</button>
      <button type="button" id="d99" (click)="deptId.set(99)">Departamento 99</button>
    </div>

    <dl class="datos">
      <dt>status()</dt><dd id="st">{{ departamento.status() }}</dd>
      <dt>statusCode()</dt><dd id="sc">{{ departamento.statusCode() }}</dd>
      <dt>Content-Type</dt><dd id="ct">{{ tipoContenido() }}</dd>
      <dt>Valor</dt>
      <dd id="val">{{ departamento.hasValue() ? departamento.value().etiqueta : '(sin valor)' }}</dd>
      <dt>Error de la API</dt><dd id="err">{{ mensajeError() }}</dd>
    </dl>

    <h3>El mismo recurso como texto</h3>
    <pre class="traza">{{ texto.hasValue() ? texto.value() : '(sin texto)' }}</pre>
  `,
})
export class RespuestaHttp {
  protected readonly deptId = signal(10);

  protected readonly departamento = httpResource(
    () => `${API}/departamentos/${this.deptId()}`,
    { parse: aDepartamento },
  );

  protected readonly texto = httpResource.text(() => `${API}/departamentos/${this.deptId()}`);

  protected readonly tipoContenido = computed(
    () => this.departamento.headers()?.get('content-type') ?? '-',
  );

  protected readonly mensajeError = computed(() => {
    const error = this.departamento.error();
    if (error instanceof HttpErrorResponse) {
      // error.error es el cuerpo JSON: { status, mensaje }
      return `${error.status}: ${error.error?.mensaje}`;
    }
    return error?.message ?? '-';
  });
}

Tabla de datos del departamento 99: estado error, código 404, tipo de contenido JSON, sin valor, y el mensaje Departamento 99 no encontrado

Figura 9. En un error HTTP siguen disponibles el código, las cabeceras y el cuerpo de la respuesta.

Salida al pedir los departamentos 30 y 99:

status()      resolved                        error
statusCode()  200                             404
Content-Type  application/json; charset=utf-8 application/json; charset=utf-8
Valor         INFORMÁTICA (Valencia)          (sin valor)
Error de API  -                               404: Departamento 99 no encontrado

error() es un HttpErrorResponse, y su propiedad error contiene el cuerpo que envió la API, con la forma { status, mensaje }. Si parse lanza una excepción, el recurso pasa a error, que es justo lo que quieres si la API ha cambiado de formato sin avisar.

💡 Consejo: parse es el sitio natural para una biblioteca de validación con esquemas, como Zod. Pásale la función de análisis del esquema y tendrás tipos correctos sin escribir la interfaz a mano.

Otras variantes de respuesta

Función Valor del recurso
httpResource(...) JSON ya analizado
httpResource.text(...) string
httpResource.blob(...) Blob, para ficheros e imágenes
httpResource.arrayBuffer(...) ArrayBuffer, para datos binarios

⚠️ Importante: Cada recurso hace su propia petición. En el ejemplo anterior, departamento y texto usan la misma URL, y en la red se ven dos peticiones. httpResource no es una caché: si varios componentes necesitan el mismo dato, compártelo desde un servicio (sección 12).


6. rxResource: cuando la carga es un Observable 🔄 v22.0

🔄 Cambia en Angular 22.0. rxResource pasa de experimental a estable. Si usas la LTS: se usa igual, importado desde @angular/core/rxjs-interop, pero es experimental y su opción se llama igualmente stream.

rxResource es resource con un cambio: en lugar de loader tiene stream, una función que devuelve un Observable. Viene bien cuando ya tienes servicios basados en Observable, o cuando quieres componer peticiones con operadores de RxJS.

RxJS se estudia en el módulo 14, y su relación con las señales en el módulo 15. Basta con saber que forkJoin espera a varias peticiones y retry las repite si fallan.

// src/app/ficha-departamento/ficha-departamento.ts
import { HttpClient } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { forkJoin, map, retry } from 'rxjs';
import { API } from '../api';
import { Departamento, Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-ficha-departamento',
  template: `
    <div class="botones">
      @for (id of [10, 20, 30]; track id) {
        <button type="button" [class.activo]="id === deptId()" (click)="deptId.set(id)">{{ id }}</button>
      }
    </div>

    @if (ficha.isLoading()) {
      <p class="aviso">Cargando...</p>
    } @else if (ficha.hasValue()) {
      <h2>{{ ficha.value().departamento.nombre }}</h2>
      <p id="plantilla">{{ ficha.value().plantilla }} empleados</p>
    }
  `,
})
export class FichaDepartamento {
  private readonly http = inject(HttpClient);
  protected readonly deptId = signal(20);

  protected readonly ficha = rxResource({
    params: () => this.deptId(),
    stream: ({ params }) =>
      forkJoin({
        departamento: this.http.get<Departamento>(`${API}/departamentos/${params}`),
        empleados: this.http.get<Pagina<Empleado>>(`${API}/departamentos/${params}/empleados`),
      }).pipe(
        retry({ count: 2, delay: 500 }),
        map(({ departamento, empleados }) => ({ departamento, plantilla: empleados.total })),
      ),
  });
}

Salida con el departamento 20:

VENTAS
6 empleados

El recurso se suscribe al Observable por ti, y cancela la suscripción si cambian los parámetros o se destruye el contexto. Por eso no hay abortSignal en el ejemplo: al cancelar la suscripción, HttpClient aborta la petición. El parámetro de stream es el mismo objeto que recibe loader, con params, abortSignal y previous.

📘 Concepto: Un Observable puede emitir muchas veces. Con rxResource, cada emisión sustituye el valor del recurso. Un Observable que falla deja el recurso en error.


7. Encadenar recursos 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: lee el recurso padre desde params con hasValue() dentro de un computed, como en CadenaLts; el hijo queda en idle mientras no haya valor, y los errores del padre no se propagan.

A menudo una carga depende del resultado de otra: con el empleado sabes quién es su jefe, y con el jefe, su departamento. La función de params recibe un ResourceParamsContext, cuyo método chain lee un recurso del que dependes y se ocupa de lo que pasa si aún no está listo:

// src/app/cadena-empleado/cadena-empleado.ts
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { Component, ResourceDependencyError, computed, signal } from '@angular/core';
import { API } from '../api';
import { Departamento, Empleado } from '../modelos';

@Component({
  selector: 'app-cadena-empleado',
  template: `
    <div class="botones">
      <button type="button" id="e110" (click)="empId.set(110)">Empleado 110</button>
      <button type="button" id="e100" (click)="empId.set(100)">Empleado 100</button>
      <button type="button" id="e9999" (click)="empId.set(9999)">Empleado 9999</button>
      <button type="button" id="enada" (click)="empId.set(undefined)">Ninguno</button>
    </div>

    <ol class="cadena">
      <li>
        <b>Empleado</b> <span class="estado">{{ empleado.status() }}</span>
        @if (empleado.hasValue()) { {{ empleado.value().nombre }} {{ empleado.value().apellidos }} }
      </li>
      <li>
        <b>Jefe</b> <span class="estado">{{ jefe.status() }}</span>
        @if (jefe.hasValue()) { {{ jefe.value().nombre }} {{ jefe.value().apellidos }} }
      </li>
      <li>
        <b>Departamento del jefe</b> <span class="estado">{{ departamento.status() }}</span>
        @if (departamento.hasValue()) { {{ departamento.value().nombre }} }
      </li>
    </ol>
    <p class="error" id="causa">{{ causa() }}</p>
  `,
})
export class CadenaEmpleado {
  protected readonly empId = signal<number | undefined>(110);

  protected readonly empleado = httpResource<Empleado>(() => {
    const id = this.empId();
    return id === undefined ? undefined : `${API}/empleados/${id}`;
  });

  protected readonly jefe = httpResource<Empleado>((ctx) => {
    const empleado = ctx.chain(this.empleado);
    // El director general no tiene jefe: este recurso queda inactivo
    return empleado?.jefeId ? `${API}/empleados/${empleado.jefeId}` : undefined;
  });

  protected readonly departamento = httpResource<Departamento>((ctx) => {
    const jefe = ctx.chain(this.jefe);
    return `${API}/departamentos/${jefe?.deptId}`;
  });

  protected readonly causa = computed(() => {
    // Cada eslabón envuelve el error del anterior: bajamos hasta el original
    let error: unknown = this.departamento.error();
    while (error instanceof ResourceDependencyError) {
      error = error.cause;
    }
    return error instanceof HttpErrorResponse ? `Causa original: HTTP ${error.status}` : '';
  });
}

La función de jefe recibe ctx, y ctx.chain(this.empleado) solo devuelve algo cuando el empleado está disponible. Si no, lanza una señal especial que detiene params y pone al hijo en el estado adecuado, de modo que nunca verás una URL con undefined.

Tres recursos encadenados con sus estados y una tabla: el padre idle deja idle al hijo; loading o reloading, loading; error, error; resolved o local, se carga con el valor

Figura 10. chain traduce el estado del padre al del hijo.

Salida real de la cadena para el empleado 110, leyendo cada 500 ms con una API que tarda ese tiempo:

Empleado resolved  Carmen Ruiz Ortega / Jefe loading                    / Departamento del jefe loading
Empleado resolved  Carmen Ruiz Ortega / Jefe resolved Luis Martínez Ruiz / Departamento del jefe loading
Empleado resolved  Carmen Ruiz Ortega / Jefe resolved Luis Martínez Ruiz / Departamento del jefe resolved VENTAS

Tres filas con el empleado, su jefe y el departamento del jefe, todas en estado resolved

Figura 11. Cada eslabón se carga cuando termina el anterior.

Y con los otros casos:

Empleado 100 (sin jefe):  Empleado resolved / Jefe idle  / Departamento idle
Empleado 9999:            Empleado error    / Jefe error / Departamento error
Ninguno:                  Empleado idle     / Jefe idle  / Departamento idle

Las tres filas en estado error y el texto Causa original: HTTP 404

Figura 12. El error baja por la cadena. El de cada hijo es un ResourceDependencyError.

Cada hijo recibe un ResourceDependencyError, que apunta al padre en dependency y guarda el error de este en cause. En una cadena de tres, el último envuelve al segundo, y el segundo al primero; por eso causa() recorre la cadena con un bucle hasta encontrar el HttpErrorResponse original.

⚠️ Importante: El tipo de ctx.chain(this.empleado) es el del padre, que sin defaultValue incluye undefined, aunque en la práctica chain no devuelva tal cosa en este caso. De ahí el ?. en empleado?.jefeId. Además, si el padre está en reloading, el hijo pasa a loading y pierde su valor: refrescar el padre hace parpadear a todos los hijos.

💡 Consejo: chain lanza ResourceParamsStatus.IDLE o ResourceParamsStatus.LOADING. Tú también puedes lanzarlos desde params para forzar esos estados, aunque devolver undefined suele ser más claro para idle.

La alternativa en la LTS

ctx no existe en Angular 21, pero se puede obtener casi lo mismo con un computed. Este componente compila y funciona en las dos versiones:

// src/app/cadena-lts/cadena-lts.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { API } from '../api';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-cadena-lts',
  template: `
    <p>Empleado: <b id="l1">{{ empleado.status() }}</b></p>
    <p>Jefe: <b id="l2">{{ jefe.status() }}</b> {{ jefe.hasValue() ? jefe.value().nombre : '' }}</p>
    <button type="button" id="l9999" (click)="empId.set(9999)">Empleado 9999</button>
  `,
})
export class CadenaLts {
  protected readonly empId = signal(110);

  protected readonly empleado = httpResource<Empleado>(() => `${API}/empleados/${this.empId()}`);

  // computed que solo da un id cuando el padre tiene valor
  private readonly jefeId = computed(() =>
    this.empleado.hasValue() ? this.empleado.value()?.jefeId : undefined,
  );

  protected readonly jefe = httpResource<Empleado>(() => {
    const id = this.jefeId();
    return id ? `${API}/empleados/${id}` : undefined;
  });
}

La diferencia está en los estados. Con el empleado 9999 esta versión da error en el padre e idle en el hijo, en lugar de propagar el error; y mientras carga el padre, el hijo está en idle, no en loading. Tu plantilla debe tenerlo en cuenta.


8. Valores parciales con stream

A veces los datos no llegan de golpe: un chat, un informe generado por partes o, como aquí, un catálogo que se pide página a página. La opción stream sustituye a loader. Devuelve una señal con objetos ResourceStreamItem<T>, que son { value } o { error }, y cada escritura en esa señal actualiza el recurso:

// src/app/catalogo-progresivo/catalogo-progresivo.ts
import { Component, ResourceStreamItem, resource, signal } from '@angular/core';
import { API } from '../api';
import { Pagina, Producto } from '../modelos';

@Component({
  selector: 'app-catalogo-progresivo',
  template: `
    <p>
      <span class="estado" id="ep">{{ catalogo.status() }}</span>
      <span id="np">{{ catalogo.hasValue() ? catalogo.value().length : 0 }} productos</span>
    </p>
    <button type="button" (click)="catalogo.reload()">Recargar</button>
  `,
})
export class CatalogoProgresivo {
  protected readonly catalogo = resource<Producto[], void>({
    defaultValue: [],
    stream: async ({ abortSignal }) => {
      const salida = signal<ResourceStreamItem<Producto[]>>({ value: [] });
      void this.leerPaginas(salida, abortSignal);
      return salida;
    },
  });

  private async leerPaginas(
    salida: ReturnType<typeof signal<ResourceStreamItem<Producto[]>>>,
    abortSignal: AbortSignal,
  ) {
    const acumulado: Producto[] = [];
    try {
      for (let pagina = 1; ; pagina++) {
        const url = `${API}/productos?tamano=8&pagina=${pagina}`;
        const respuesta = await fetch(url, { signal: abortSignal });
        const datos = (await respuesta.json()) as Pagina<Producto>;
        acumulado.push(...datos.contenido);
        salida.set({ value: [...acumulado] }); // nuevo valor parcial
        if (pagina >= datos.paginas) {
          return;
        }
      }
    } catch (error) {
      salida.set({ error: error as Error });
    }
  }
}

Lectura de la pantalla cada medio segundo, con 20 productos en páginas de 8:

resolved 8 productos
resolved 16 productos
resolved 20 productos

⚠️ Importante: El estado pasa a resolved con el primer valor, no al terminar, e isLoading() deja de ser true en ese momento. resource no sabe cuándo acaba tu flujo. Si necesitas mostrar «sigue cargando», usa una señal propia.

En Angular 22.0 stream también puede devolver la señal directamente, sin promesa. En la LTS debe devolver una promesa de la señal, como hace este ejemplo con async, que compila en ambas.

Caché en SSR con id 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: resource y rxResource no tienen esta opción; httpResource sí aprovecha la caché de transferencia de HttpClient, así que úsalo para las cargas HTTP de páginas renderizadas en el servidor.

La opción id de resource y rxResource da un identificador con el que guardar el valor durante el renderizado en servidor y recuperarlo en el cliente. Debe ser idéntico en ambos lados. Lo pondrás en práctica en el módulo 24.


9. Escribir en un recurso

Un recurso no es de solo lectura: set y update escriben en su valor, y lo dejan en estado local. value es además una señal escribible, de modo que recurso.value.set(x) hace lo mismo.

Situación Efecto de set
resolved Pasa a local con el valor nuevo
loading o reloading Aborta la carga y pasa a local
error Borra el error y pasa a local

En un httpResource, tras set, también se vacían statusCode() y headers(), porque el valor ya no procede de esa respuesta.

⚠️ Importante: set solo cambia el dato en el navegador. No envía nada al servidor, y resource tampoco sirve para enviarlo: los recursos están pensados para leer. Una carga cancelada a medias no es grave, pero un POST cancelado porque cambió una señal sí. Las escrituras se hacen desde un método, con HttpClient.

Actualización optimista

Una actualización optimista muestra el cambio al instante, sin esperar al servidor, y lo deshace si este lo rechaza. La aplicación parece mucho más rápida, y set encaja perfectamente con esta técnica:

// src/app/salario-optimista/salario-optimista.ts
import { HttpClient, HttpErrorResponse, httpResource } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API } from '../api';
import { Empleado } from '../modelos';

@Component({
  selector: 'app-salario-optimista',
  template: `
    @if (empleado.hasValue() && empleado.value(); as e) {
      <article class="ficha">
        <h2>{{ e.nombre }} {{ e.apellidos }}</h2>
        <p class="salario">Salario: <b id="sal">{{ e.salario }} €</b></p>
        <p>Estado del recurso: <span class="estado" id="es">{{ empleado.status() }}</span></p>
      </article>

      <div class="botones">
        <button type="button" id="entrar" [disabled]="token()" (click)="entrar()">Iniciar sesión</button>
        <button type="button" id="subir" [disabled]="guardando()" (click)="subir(100)">Subir 100 €</button>
        <button type="button" (click)="empleado.reload()">Recargar</button>
      </div>
    }

    @if (mensaje(); as texto) {
      <p class="error" id="msg">{{ texto }}</p>
    }
  `,
})
export class SalarioOptimista {
  private readonly http = inject(HttpClient);

  protected readonly token = signal('');
  protected readonly guardando = signal(false);
  protected readonly mensaje = signal('');

  protected readonly empleado = httpResource<Empleado>(() => `${API}/empleados/101`);

  protected async entrar() {
    const respuesta = await firstValueFrom(
      this.http.post<{ token: string }>(`${API}/auth/login`, {
        email: 'ana.garcia@empresa.es',
        password: 'secreto',
      }),
    );
    this.token.set(respuesta.token);
    this.mensaje.set('');
  }

  protected async subir(cantidad: number) {
    const anterior = this.empleado.value();
    if (!anterior) {
      return;
    }
    const cambiado = { ...anterior, salario: anterior.salario + cantidad };

    this.mensaje.set('');
    this.guardando.set(true);
    this.empleado.set(cambiado); // 1. la pantalla cambia ya: estado 'local'

    try {
      const guardado = await firstValueFrom(
        this.http.put<Empleado>(`${API}/empleados/${anterior.empId}`, cambiado, {
          headers: { Authorization: `Bearer ${this.token()}` },
        }),
      );
      this.empleado.set(guardado); // 2. nos quedamos con lo que dice el servidor
    } catch (error) {
      this.empleado.set(anterior); // 3. deshacemos el cambio
      const estado = error instanceof HttpErrorResponse ? error.status : '?';
      this.mensaje.set(`No se guardó (HTTP ${estado}). Se ha restaurado el salario.`);
    } finally {
      this.guardando.set(false);
    }
  }
}

Los tres pasos están numerados en el método subir. Guardamos el valor anterior antes de tocar nada, porque es lo que permite volver atrás. La primera pulsación, sin iniciar sesión, da un 401:

Ficha del empleado con el salario de 4900 euros y estado local

Figura 13. Nada más pulsar: el salario ya es 4900 €, y aún no hay respuesta del servidor.

La misma ficha con el salario restaurado a 4800 euros y el aviso No se guardó, HTTP 401

Figura 14. Medio segundo después: el servidor ha rechazado el cambio y se ha restaurado el valor.

Secuencia observada:

Inicio            4800 €  resolved
Pulsar, sin sesión 4900 €  local
Respuesta 401     4800 €  local    No se guardó (HTTP 401). Se ha restaurado el salario.
Con sesión        4900 €  local

Fíjate en que el estado final es local, aunque el dato venga del servidor. Si prefieres volver a resolved, no uses set(guardado): llama a empleado.reload(), a costa de otra petición.

💡 Consejo: Para simplificar, el ejemplo envía la cabecera Authorization a mano. En una aplicación real lo hace un interceptor, como viste en el módulo 12.

¿Optimista, o recargar después?

Estrategia Ventaja Inconveniente
Esperar y luego reload() Sencilla; el dato es el del servidor La interfaz tarda
Optimista con set Respuesta inmediata Hay que deshacer y avisar del fallo
Guardar y set con la respuesta Una sola petición No sirve si el cambio afecta a otros datos, como los totales

10. snapshot: componer recursos

snapshot 🔄 v22.0

🔄 Cambia en Angular 22.0. snapshot y el tipo ResourceSnapshot pasan de experimentales a estables. Si usas la LTS: existen desde Angular 21.2 y se usan igual, pero son experimentales; en 21.0 y 21.1 no están disponibles.

snapshot() es una señal con todo el estado en un solo objeto. Es una unión discriminada: según status, el objeto tiene value o error.

type ResourceSnapshot<T> =
  | { status: 'idle'; value: T }
  | { status: 'loading' | 'reloading'; value: T }
  | { status: 'resolved' | 'local'; value: T }
  | { status: 'error'; error: Error };

Como repasaste en el módulo 02, TypeScript estrecha el tipo en cada rama de un switch. Y aquí no hay riesgo de excepciones: en la rama error no puedes leer value, ni falla nada, porque esa propiedad no existe.

Conservar el valor mientras carga

Con snapshot y resourceFromSnapshots se puede crear un recurso a partir de otro, transformando sus estados. Vamos a arreglar el parpadeo de la figura 8 con una función reutilizable:

// src/app/compartido/conservar-valor.ts
import { Resource, ResourceSnapshot, linkedSignal, resourceFromSnapshots } from '@angular/core';

// Devuelve un recurso que no pierde el valor mientras carga uno nuevo
export function conservarValor<T>(origen: Resource<T>): Resource<T> {
  const instantanea = linkedSignal<ResourceSnapshot<T>, ResourceSnapshot<T>>({
    source: origen.snapshot,
    computation: (actual, anterior) => {
      const previa = anterior?.value;
      if (actual.status === 'loading' && previa && previa.status !== 'error') {
        return { status: 'loading', value: previa.value };
      }
      return actual;
    },
  });
  return resourceFromSnapshots(instantanea);
}

⚠️ Importante: resourceFromSnapshots es experimental tanto en Angular 22.2 como en 21.2: su API puede cambiar. Encierra su uso en una función como esta, para tocar un solo sitio si cambia. Si prefieres no depender de ella, el ejercicio 7 consigue lo mismo con un simple linkedSignal.

linkedSignal recuerda su valor anterior. Si el recurso original está en loading y antes había un valor, devuelve un loading que conserva ese valor. El resultado, resourceFromSnapshots, es un recurso normal, de solo lectura, que se usa así:

// src/app/tabla-estable/tabla-estable.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { API } from '../api';
import { conservarValor } from '../compartido/conservar-valor';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-tabla-estable',
  template: `
    <div class="botones">
      <button type="button" id="p1" (click)="pagina.set(1)">Página 1</button>
      <button type="button" id="p2" (click)="pagina.set(2)">Página 2</button>
      <span class="estado" id="ee">{{ estable.status() }}</span>
    </div>
    <p id="instantanea">{{ resumen() }}</p>
    <ul [class.apagada]="estable.isLoading()">
      @for (e of filas(); track e.empId) {
        <li>{{ e.apellidos }}, {{ e.nombre }}</li>
      }
    </ul>
  `,
})
export class TablaEstable {
  protected readonly pagina = signal(1);

  private readonly empleados = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: { pagina: this.pagina(), tamano: 4 },
  }));

  protected readonly estable = conservarValor(this.empleados);

  protected readonly filas = computed(() =>
    this.estable.hasValue() ? (this.estable.value()?.contenido ?? []) : [],
  );

  // snapshot agrupa estado y valor en un solo objeto
  protected readonly resumen = computed(() => {
    const foto = this.empleados.snapshot();
    switch (foto.status) {
      case 'idle':
        return 'Sin datos';
      case 'loading':
      case 'reloading':
        return 'Original: cargando';
      case 'error':
        return `Original: error (${foto.error.message})`;
      default:
        return `Original: página ${foto.value?.pagina}`;
    }
  });
}

Salida 150 ms después de pulsar «Página 2», y al terminar:

estable: loading    Original: cargando   primera fila: García López, Ana
estable: resolved   Original: página 2   primera fila: Fernández Moreno, Laura

Lista de cuatro empleados atenuada, con la etiqueta loading y el texto Original: cargando

Figura 15. Mientras carga la página 2, la lista sigue en pantalla, atenuada.

El estado real sigue siendo loading, y eso es lo correcto: el valor mostrado es solo un recuerdo, y isLoading() permite atenuarlo para que se note.

Retrasar búsquedas con debounced 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21), y en 22 sigue siendo experimental. Si usas la LTS: espera dentro del loader con una promesa ligada a abortSignal, como en BuscadorLts, o usa debounceTime de RxJS (módulo 15).

Si cada tecla lanza una petición, escribir «garcia» supone seis peticiones. debounced, presentado en el módulo 05, crea un recurso que sigue a una señal, pero solo se resuelve cuando esta lleva un tiempo sin cambiar:

// src/app/buscador-debounced/buscador-debounced.ts
import { httpResource } from '@angular/common/http';
import { Component, debounced, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-buscador-debounced',
  template: `
    <input id="bd" type="search" (input)="termino.set($any($event.target).value)" />
    <span class="estado" id="bde">{{ resultados.status() }}</span>
    <p id="bdn">{{ resultados.hasValue() ? resultados.value().total : '-' }} resultados</p>
  `,
})
export class BuscadorDebounced {
  protected readonly termino = signal('');

  // Recurso que solo se resuelve 300 ms después de la última tecla
  private readonly terminoEstable = debounced(this.termino, 300);

  protected readonly resultados = httpResource<Pagina<Empleado>>((ctx) => ({
    url: `${API}/empleados`,
    params: { q: ctx.chain(this.terminoEstable) },
  }));
}

Secuencia al teclear «gar» con 60 ms entre teclas:

Al abrir:          resolved  24 resultados
Justo tras teclear: loading  - resultados
Un segundo después: resolved 1 resultados

Como terminoEstable es un recurso, ctx.chain hace el trabajo: mientras cuenta el tiempo está en loading, y resultados también. Para evitar que la lista se vacíe en cada pausa, combínalo con conservarValor.

La alternativa sin debounced

Un truco que funciona en las dos versiones aprovecha el abortSignal. El loader espera antes de pedir nada, y cada tecla aborta la espera anterior:

// src/app/buscador-lts/buscador-lts.ts
import { Component, resource, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

// Promesa que se rechaza si se aborta antes de que pase el tiempo
function esperar(ms: number, abortSignal: AbortSignal): Promise<void> {
  return new Promise((resolver, rechazar) => {
    const temporizador = setTimeout(resolver, ms);
    abortSignal.addEventListener('abort', () => {
      clearTimeout(temporizador);
      rechazar(abortSignal.reason);
    });
  });
}

@Component({
  selector: 'app-buscador-lts',
  template: `
    <input id="bl" type="search" (input)="termino.set($any($event.target).value)" />
    <span class="estado" id="ble">{{ resultados.status() }}</span>
    <p id="bln">{{ resultados.hasValue() ? resultados.value().total : '-' }} resultados</p>
    <p id="blp">{{ peticiones() }} peticiones enviadas</p>
  `,
})
export class BuscadorLts {
  protected readonly termino = signal('');
  protected readonly peticiones = signal(0);

  protected readonly resultados = resource({
    params: () => this.termino(),
    loader: async ({ params, abortSignal }) => {
      await esperar(300, abortSignal); // cada tecla aborta la espera anterior
      this.peticiones.update((n) => n + 1);
      const url = `${API}/empleados?q=${encodeURIComponent(params)}`;
      const respuesta = await fetch(url, { signal: abortSignal });
      return (await respuesta.json()) as Pagina<Empleado>;
    },
  });
}

Con las mismas tres teclas, el contador muestra 2 peticiones enviadas: la carga inicial y la final. Las teclas intermedias nunca llegaron a la red.

Observa que en esperar el rechazo de la promesa no da problemas: Angular descarta el resultado de una carga abortada, de modo que no verás ese error.


11. Recursos en plantillas

Hay tres patrones habituales, y conviene elegir uno y usarlo siempre igual.

@if encadenado. El más flexible. El orden importa: primero la carga, luego el error y por último los datos.

@if (empleado.isLoading()) {
  <p>Cargando...</p>
} @else if (empleado.error(); as error) {
  <p>{{ error.message }}</p>
} @else if (empleado.hasValue()) {
  <h2>{{ empleado.value().nombre }}</h2>
}

@switch sobre status(). El más explícito, útil cuando cada estado se ve muy distinto. Tienes uno en la solución del ejercicio 3.

@switch (pedidos.status()) {
  @case ('idle') { <p>Selecciona un cliente.</p> }
  @case ('loading') { <p>Cargando...</p> }
  @case ('error') { <p>No se han podido cargar.</p> }
  @default { <p>{{ pedidos.value().total }} pedidos</p> }
}

Con defaultValue. Si solo hay que atenuar la vista mientras se refresca, una clase condicional basta, pero sigue protegiendo el caso del error:

@if (pedidos.error()) {
  <p>Error</p>
} @else {
  <ul [class.apagada]="pedidos.isLoading()">...</ul>
}

💡 Consejo: hasValue() estrecha el tipo en la plantilla solo si el recurso es una propiedad, como empleado. Si procede de una llamada, por ejemplo un input() que se lee con recurso(), cada llamada es una expresión distinta y no hay estrechamiento: usa ?..

Un componente para no repetir

Escribir esos tres casos en cada pantalla cansa. Este componente los encapsula:

// src/app/compartido/estado-recurso.ts
import { NgTemplateOutlet } from '@angular/common';
import { Component, Resource, TemplateRef, contentChild, input, output } from '@angular/core';

@Component({
  selector: 'app-estado-recurso',
  imports: [NgTemplateOutlet],
  template: `
    @if (recurso().isLoading() && !recurso().hasValue()) {
      <div class="esqueleto" aria-busy="true"></div>
    } @else if (recurso().error()) {
      <div class="error" role="alert">
        <p>{{ mensaje() }}</p>
        <button type="button" (click)="reintentar.emit()">Reintentar</button>
      </div>
    } @else if (recurso().hasValue()) {
      <ng-container [ngTemplateOutlet]="contenido()" />
    }
  `,
})
export class EstadoRecurso {
  readonly recurso = input.required<Resource<unknown>>();
  readonly mensaje = input('No se han podido cargar los datos.');
  readonly reintentar = output();

  protected readonly contenido = contentChild.required(TemplateRef);
}

Y se usa así, con Resource<unknown> como entrada para aceptar cualquier recurso:

// src/app/tarjetas-empleado/tarjetas-empleado.ts
import { httpResource } from '@angular/common/http';
import { Component } from '@angular/core';
import { API } from '../api';
import { EstadoRecurso } from '../compartido/estado-recurso';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-tarjetas-empleado',
  imports: [EstadoRecurso],
  template: `
    <div class="rejilla">
      <section>
        <h3>Correcto</h3>
        <app-estado-recurso [recurso]="directivos" (reintentar)="directivos.reload()">
          <ng-template>
            <ul>
              @for (e of directivos.value()?.contenido; track e.empId) {
                <li>{{ e.nombre }} {{ e.apellidos }}</li>
              }
            </ul>
          </ng-template>
        </app-estado-recurso>
      </section>

      <section>
        <h3>Con error</h3>
        <app-estado-recurso
          mensaje="Ese empleado no existe."
          [recurso]="inexistente"
          (reintentar)="inexistente.reload()"
        >
          <ng-template>
            <p>{{ inexistente.value()?.nombre }}</p>
          </ng-template>
        </app-estado-recurso>
      </section>

      <section>
        <h3>Cargando</h3>
        <app-estado-recurso [recurso]="lento">
          <ng-template><p>Listo</p></ng-template>
        </app-estado-recurso>
      </section>
    </div>
  `,
})
export class TarjetasEmpleado {
  protected readonly directivos = httpResource<Pagina<Empleado>>(
    () => `${API}/empleados?tamano=3`,
  );
  protected readonly inexistente = httpResource<Empleado>(() => `${API}/empleados/9999`);
  protected readonly lento = httpResource<Empleado>(() => `${API}/empleados/100?lento=1`);
}

Tres tarjetas: una lista de empleados, un error con el botón Reintentar y un bloque gris de carga

Figura 16. El mismo componente en sus tres situaciones.

⚠️ Importante: El contenido va en un <ng-template>, no directamente con <ng-content>. El contenido proyectado pertenece a la plantilla del padre y se evalúa aunque no se muestre, de modo que un {{ recurso.value() }} proyectado lanzaría la excepción en estado de error. Con ng-template, el contenido solo se crea cuando EstadoRecurso lo decide.

Este último ejemplo solo muestra que los errores de un recurso no afectan a los demás. Para que un error no tumbe toda una zona de la pantalla, en el módulo 23 verás @boundary.


12. Recursos en servicios y ciclo de vida

Contexto de inyección

Un recurso necesita un inyector para crear su effect interno y engancharse al DestroyRef. Por eso se declara como propiedad de la clase, igual que inject(), como viste en el módulo 08. Si lo creas en un método sin más, obtienes:

NG0203: httpResource() can only be used within an injection context such as a constructor, a factory function, a field initializer, ...

La solución es declararlo como propiedad o, si de verdad debe crearse más tarde, pasar la opción injector.

Compartir datos entre componentes

Un recurso en un servicio raíz vive tanto como la aplicación, hace la petición una sola vez y lo comparten todos sus usuarios:

// src/app/compartido/catalogo.ts
import { httpResource } from '@angular/common/http';
import { Injectable, Injector, inject } from '@angular/core';
import { API } from '../api';
import { Departamento, Empleado, Pagina } from '../modelos';

@Injectable({ providedIn: 'root' })
export class Catalogo {
  private readonly injector = inject(Injector);

  private readonly recursoDepartamentos = httpResource<Pagina<Departamento>>(
    () => `${API}/departamentos`,
  );

  // Hacia fuera, solo lectura: nadie puede hacer set() desde un componente
  readonly departamentos = this.recursoDepartamentos.asReadonly();

  // Se puede llamar desde cualquier sitio porque pasamos el injector
  empleadoBajoDemanda(id: number) {
    return httpResource<Empleado>(() => `${API}/empleados/${id}`, { injector: this.injector });
  }

  // Mal: sin injector y fuera de un contexto de inyección
  empleadoSinContexto(id: number) {
    return httpResource<Empleado>(() => `${API}/empleados/${id}`);
  }
}

asReadonly() devuelve el mismo recurso con el tipo Resource<T>, sin set ni reload. Es una protección de TypeScript: en ejecución el objeto sigue teniendo esos métodos, pero el compilador no te deja usarlos.

⚠️ Importante: Un recurso creado con el injector del servicio vive tanto como ese servicio. Si empleadoBajoDemanda se llama muchas veces, se acumulan recursos: guarda lo devuelto y llama a destroy() cuando ya no lo necesites.

Recursos y estabilidad de la aplicación

Mientras un recurso carga, Angular considera que la aplicación aún no está estable, gracias a PendingTasks. Eso importa en dos casos: en SSR, donde el servidor espera a los recursos antes de enviar el HTML (módulo 24), y en las pruebas, que vienen a continuación.


13. Probar un recurso

httpResource usa HttpClient, de modo que se prueba con provideHttpClientTesting, como en el módulo 26. El detalle importante es cuándo se lanza la petición:

// src/app/lista-empleados/lista-empleados.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ApplicationRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { ListaEmpleados } from './lista-empleados';

describe('ListaEmpleados', () => {
  it('pide la primera página y pinta las filas', async () => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    const fixture = TestBed.createComponent(ListaEmpleados);
    const http = TestBed.inject(HttpTestingController);
    TestBed.tick(); // ejecuta params y lanza la petición

    const peticion = http.expectOne((r) => r.url.endsWith('/empleados'));
    expect(peticion.request.params.get('pagina')).toBe('1');

    peticion.flush({
      contenido: [{ empId: 100, nombre: 'Ana', apellidos: 'García', oficio: 'DIRECTORA', salario: 6500 }],
      pagina: 1,
      tamano: 5,
      total: 1,
      paginas: 1,
    });
    await TestBed.inject(ApplicationRef).whenStable();

    expect(fixture.nativeElement.textContent).toContain('García, Ana');
    http.verify();
  });
});
Tests  1 passed (1)

La petición sale de un effect, que se ejecuta en la siguiente pasada. TestBed.tick() fuerza esa pasada de forma síncrona, y entonces expectOne ya la encuentra.

⚠️ Importante: No uses await fixture.whenStable() antes de responder a la petición. La aplicación no estará estable hasta que el recurso termine, y este no terminará hasta que llames a flush. La prueba se queda esperando hasta que agota el tiempo: Test timed out in 5000ms.


14. ¿Qué herramienta uso?

Árbol de decisión: escrituras con HttpClient; lecturas HTTP con httpResource; Observables con rxResource; el resto con resource

Figura 17. Criterio para elegir.

Necesidad Herramienta
Leer JSON de una API según unas señales httpResource
Leer de una biblioteca con promesas, de IndexedDB o de un SDK resource
Reutilizar un Observable, o aplicar forkJoin o retry rxResource
Crear, modificar o borrar HttpClient en un método
Datos que dependen de otros datos ctx.chain (Angular 22)
Un flujo de eventos complejo: websockets, varias fuentes RxJS con toSignal (módulo 15)

Frente a toSignal(http.get(...)), un recurso se vuelve a lanzar solo cuando cambian sus parámetros y ofrece estados, recarga y escritura sin programar nada. toSignal es mejor cuando ya tienes el Observable y solo quieres leerlo.

Resumen de diferencias con la LTS

Función Angular 22.2 Angular 21.2
resource, httpResource, rxResource Estables Experimentales
snapshot Estable Experimental
ctx.chain, ResourceDependencyError, ResourceParamsStatus Sí No existen
Excepción en params Estado error Error sin tratar
params da el mismo valor No recarga Recarga
Opción id Sí No existe
debounced Experimental No existe
resourceFromSnapshots Experimental Experimental

15. Errores frecuentes

Síntoma Causa Solución
Resource is currently in an error state y la vista se congela Leer value() en estado de error Comprobar hasValue() o error() antes
El recurso no se actualiza al cambiar una señal La señal se lee en el loader Moverla a params
NG0203 al crear el recurso Fuera de un contexto de inyección Declararlo como propiedad, o usar injector
La lista se vacía al cambiar de página loading descarta el valor conservarValor o linkedSignal
«Reintentar» parece no hacer nada error() sigue activo durante reloading Preguntar antes por isLoading()
Peticiones duplicadas Dos recursos con la misma URL Compartir desde un servicio
El JSON de error aparece como dato fetch no falla con un 404 Comprobar respuesta.ok
La petición en curso no se cancela No se pasó abortSignal a fetch fetch(url, { signal: abortSignal })
Una URL contiene undefined Leer un dato del padre sin comprobar ctx.chain, o devolver undefined
Cambio perdido tras recargar set no envía nada al servidor Guardar con HttpClient
La prueba agota el tiempo whenStable antes de flush TestBed.tick() antes de expectOne
El servidor no recibe una escritura Se ha usado un recurso para un POST Un método con HttpClient

16. Ejercicios

Todos usan la API EMPRESA en http://localhost:3000/api y los tipos de modelos.ts:

// src/app/modelos.ts
export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  oficio: string;
  salario: number;
  jefeId: number | null;
  deptId: number;
  departamento: string;
}

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}

export interface Producto {
  productoId: number;
  nombre: string;
  descripcion: string;
  precio: number;
  stock: number;
  categoria: string;
}

export interface Pedido {
  pedidoId: number;
  clienteId: number;
  cliente: string;
  fechaPedido: string;
  estado: string;
  total: number;
}

export interface Cliente {
  clienteId: number;
  nombre: string;
  ciudad: string;
}

// Forma común de todos los listados de la API
export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}

Y la URL base, en un fichero aparte:

// src/app/api.ts
export const API = 'http://localhost:3000/api';

Para ver los estados de carga, arranca la API con retardo: RETARDO=800 node server.mjs.

Ejercicio 1: Ficha de producto

Crea FichaProducto, que reciba productoId por la ruta producto/:productoId (con withComponentInputBinding) y cargue el producto con resource y fetch. Debe cancelar lo pendiente, y distinguir un producto inexistente de cualquier otro fallo.

Ver solución
// src/app/ficha-producto/ficha-producto.ts
import { Component, input, numberAttribute, resource } from '@angular/core';
import { API } from '../api';
import { Producto } from '../modelos';

@Component({
  selector: 'app-ficha-producto',
  template: `
    @if (producto.isLoading()) {
      <p class="aviso">Cargando...</p>
    } @else if (producto.error(); as error) {
      <p class="error" id="ej1e">{{ error.message }}</p>
      <button type="button" (click)="producto.reload()">Reintentar</button>
    } @else if (producto.hasValue()) {
      <h2 id="ej1v">{{ producto.value().nombre }}</h2>
      <p>{{ producto.value().precio }} € ({{ producto.value().stock }} en stock)</p>
    }
  `,
})
export class FichaProducto {
  readonly productoId = input.required({ transform: numberAttribute });

  protected readonly producto = resource({
    params: () => this.productoId(),
    loader: async ({ params, abortSignal }) => {
      const respuesta = await fetch(`${API}/productos/${params}`, { signal: abortSignal });
      if (respuesta.status === 404) {
        throw new Error(`El producto ${params} no existe`);
      }
      if (!respuesta.ok) {
        throw new Error(`Error del servidor (HTTP ${respuesta.status})`);
      }
      return (await respuesta.json()) as Producto;
    },
  });
}

Con /producto/3 muestra Portátil Básico 15, y con /producto/999, El producto 999 no existe. numberAttribute convierte a número el texto que llega de la ruta. Como params lee un input(), navegar a otro producto recarga la ficha sin más código.

Ejercicio 2: Pedidos filtrados

Muestra los pedidos con httpResource y un <select> para filtrar por estado (PENDIENTE, ENVIADO, ENTREGADO, CANCELADO). Con la opción vacía no debe enviarse el parámetro estado. Añade un botón «Actualizar».

Ver solución
// src/app/lista-pedidos/lista-pedidos.ts
import { httpResource } from '@angular/common/http';
import { Component, signal } from '@angular/core';
import { API } from '../api';
import { Pagina, Pedido } from '../modelos';

@Component({
  selector: 'app-lista-pedidos',
  template: `
    <select id="ej2s" (change)="estado.set($any($event.target).value)">
      <option value="">Todos</option>
      @for (e of estados; track e) {
        <option [value]="e">{{ e }}</option>
      }
    </select>
    <button type="button" (click)="pedidos.reload()">Actualizar</button>

    @if (pedidos.error(); as error) {
      <p class="error">{{ error.message }}</p>
    } @else {
      <p id="ej2n">{{ pedidos.value().total }} pedidos</p>
      <ul [class.apagada]="pedidos.isLoading()">
        @for (p of pedidos.value().contenido; track p.pedidoId) {
          <li>{{ p.pedidoId }}: {{ p.cliente }} ({{ p.estado }}), {{ p.total }} €</li>
        }
      </ul>
    }
  `,
})
export class ListaPedidos {
  protected readonly estados = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];
  protected readonly estado = signal('');

  protected readonly pedidos = httpResource<Pagina<Pedido>>(
    () => {
      const estado = this.estado();
      const params: Record<string, string | number> = { tamano: 10 };
      if (estado) {
        params['estado'] = estado;
      }
      return { url: `${API}/pedidos`, params };
    },
    {
      defaultValue: { contenido: [], pagina: 1, tamano: 10, total: 0, paginas: 1 },
    },
  );
}

Salida: 24 pedidos sin filtro y 3 pedidos con PENDIENTE. El objeto params se declara como Record<string, string | number> porque, si TypeScript deduce la unión de dos objetos distintos, no encaja en HttpResourceRequest.

Ejercicio 3: Pedidos de un cliente

Muestra un <select> con los clientes. Mientras no haya ninguno elegido, el recurso de pedidos debe quedar idle, sin hacer ninguna petición. Usa @switch sobre status() y pide /clientes/:id/pedidos.

Ver solución
// src/app/pedidos-cliente/pedidos-cliente.ts
import { httpResource } from '@angular/common/http';
import { Component, signal } from '@angular/core';
import { API } from '../api';
import { Cliente, Pagina, Pedido } from '../modelos';

@Component({
  selector: 'app-pedidos-cliente',
  template: `
    <select id="ej3s" (change)="elegir($any($event.target).value)">
      <option value="">Elige un cliente</option>
      @for (c of clientes.value().contenido; track c.clienteId) {
        <option [value]="c.clienteId">{{ c.nombre }}</option>
      }
    </select>

    @switch (pedidos.status()) {
      @case ('idle') {
        <p id="ej3m">Selecciona un cliente para ver sus pedidos.</p>
      }
      @case ('loading') {
        <p class="aviso">Cargando pedidos...</p>
      }
      @case ('error') {
        <p class="error">No se han podido cargar los pedidos.</p>
      }
      @default {
        <p id="ej3m">{{ pedidos.value().total }} pedidos</p>
      }
    }
  `,
})
export class PedidosCliente {
  protected readonly clienteId = signal<number | undefined>(undefined);

  protected readonly clientes = httpResource<Pagina<Cliente>>(() => `${API}/clientes?tamano=100`, {
    defaultValue: { contenido: [], pagina: 1, tamano: 100, total: 0, paginas: 1 },
  });

  protected readonly pedidos = httpResource<Pagina<Pedido>>(
    () => {
      const id = this.clienteId();
      return id === undefined ? undefined : `${API}/clientes/${id}/pedidos`;
    },
    { defaultValue: { contenido: [], pagina: 1, tamano: 20, total: 0, paginas: 1 } },
  );

  protected elegir(valor: string) {
    this.clienteId.set(valor ? Number(valor) : undefined);
  }
}

Antes de elegir aparece Selecciona un cliente para ver sus pedidos.; al elegir, Cargando pedidos..., y luego 3 pedidos. La clave está en devolver undefined desde la función de la URL.

Ejercicio 4: Pedido, cliente y comercial

Partiendo de un pedido, carga en paralelo su cliente y el empleado que lo gestionó (campo empleadoId), encadenando los dos recursos con ctx.chain. Comprueba qué ocurre si el pedido no existe. Si usas la LTS, resuélvelo con computed, como en la sección 7.

Ver solución
// src/app/pedido-comercial/pedido-comercial.ts
import { httpResource } from '@angular/common/http';
import { Component, signal } from '@angular/core';
import { API } from '../api';
import { Cliente, Empleado, Pedido } from '../modelos';

@Component({
  selector: 'app-pedido-comercial',
  template: `
    <button type="button" id="ej4b" (click)="pedidoId.set(50)">Pedido inexistente</button>
    <p id="ej4c">Cliente: {{ cliente.status() }} {{ cliente.hasValue() ? cliente.value().ciudad : '' }}</p>
    <p id="ej4e">Comercial: {{ comercial.status() }} {{ comercial.hasValue() ? comercial.value().nombre : '' }}</p>
  `,
})
export class PedidoComercial {
  protected readonly pedidoId = signal(5001);

  protected readonly pedido = httpResource<Pedido & { empleadoId: number }>(
    () => `${API}/pedidos/${this.pedidoId()}`,
  );

  protected readonly cliente = httpResource<Cliente>((ctx) => {
    const pedido = ctx.chain(this.pedido);
    return `${API}/clientes/${pedido?.clienteId}`;
  });

  protected readonly comercial = httpResource<Empleado>((ctx) => {
    const pedido = ctx.chain(this.pedido);
    return `${API}/empleados/${pedido?.empleadoId}`;
  });
}

Salida con el pedido 5001 y con uno inexistente:

Cliente: resolved Valencia | Comercial: resolved Carmen
Cliente: error             | Comercial: error

Un recurso puede tener varios hijos: los dos dependen de pedido y se cargan a la vez cuando este termina. Ambos reciben el mismo error.

Ejercicio 5: Panel con parse

Lee /api/estadisticas con httpResource y transforma la respuesta con parse en un objeto más cómodo: número de empleados, facturación con formato de moneda y nombre del departamento con más empleados. Si el formato no es el esperado, debe verse un error.

Ver solución
// src/app/panel-resumen/panel-resumen.ts
import { httpResource } from '@angular/common/http';
import { Component } from '@angular/core';
import { API } from '../api';

interface Resumen {
  empleados: number;
  facturacion: string;
  mayorPlantilla: string;
}

interface EstadisticasApi {
  empleados: number;
  facturacion: number;
  porDepartamento: { nombre: string; empleados: number }[];
}

function aResumen(crudo: unknown): Resumen {
  const datos = crudo as EstadisticasApi;
  if (typeof datos?.empleados !== 'number' || !Array.isArray(datos.porDepartamento)) {
    throw new Error('El formato de las estadísticas ha cambiado');
  }
  const [primero] = [...datos.porDepartamento].sort((a, b) => b.empleados - a.empleados);
  return {
    empleados: datos.empleados,
    facturacion: datos.facturacion.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' }),
    mayorPlantilla: primero?.nombre ?? '-',
  };
}

@Component({
  selector: 'app-panel-resumen',
  template: `
    @if (resumen.error(); as error) {
      <p class="error">{{ error.message }}</p>
    } @else if (resumen.hasValue()) {
      <ul id="ej5">
        <li>Empleados: {{ resumen.value().empleados }}</li>
        <li>Facturación: {{ resumen.value().facturacion }}</li>
        <li>Mayor plantilla: {{ resumen.value().mayorPlantilla }}</li>
      </ul>
    } @else {
      <p class="aviso">Cargando...</p>
    }
  `,
})
export class PanelResumen {
  protected readonly resumen = httpResource(() => `${API}/estadisticas`, { parse: aResumen });
}

Salida:

Empleados: 24
Facturación: 61.617,46 €
Mayor plantilla: VENTAS

La plantilla recibe un objeto ya preparado, sin lógica de formato, y el tipo del recurso se deduce de aResumen.

Ejercicio 6: Cambio de estado optimista

Muestra el pedido 5003 y dos botones que lo pasen a ENVIADO con PATCH /api/pedidos/:id, uno sin sesión y otro iniciándola antes con luis.martinez@empresa.es. La pantalla debe cambiar al momento, y volver atrás si el servidor lo rechaza.

Ver solución
// src/app/estado-pedido/estado-pedido.ts
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API } from '../api';
import { Pedido } from '../modelos';

@Component({
  selector: 'app-estado-pedido',
  template: `
    @if (pedido.hasValue() && pedido.value(); as p) {
      <p>Pedido {{ p.pedidoId }}: <b id="ej6e">{{ p.estado }}</b></p>
      <button type="button" id="ej6sin" (click)="cambiar('ENVIADO', false)">Enviar sin sesión</button>
      <button type="button" id="ej6con" (click)="cambiar('ENVIADO', true)">Enviar con sesión</button>
    }
    <p class="error" id="ej6m">{{ aviso() }}</p>
  `,
})
export class EstadoPedido {
  private readonly http = inject(HttpClient);
  protected readonly aviso = signal('');

  protected readonly pedido = httpResource<Pedido>(() => `${API}/pedidos/5003`);

  protected async cambiar(estado: string, conSesion: boolean) {
    const anterior = this.pedido.value();
    if (!anterior) {
      return;
    }
    this.aviso.set('');
    this.pedido.set({ ...anterior, estado });

    try {
      const token = conSesion ? await this.obtenerToken() : '';
      const guardado = await firstValueFrom(
        this.http.patch<Pedido>(
          `${API}/pedidos/${anterior.pedidoId}`,
          { estado },
          { headers: { Authorization: `Bearer ${token}` } },
        ),
      );
      this.pedido.set(guardado);
    } catch {
      this.pedido.set(anterior);
      this.aviso.set('No se pudo cambiar el estado del pedido.');
    }
  }

  private async obtenerToken() {
    const sesion = await firstValueFrom(
      this.http.post<{ token: string }>(`${API}/auth/login`, {
        email: 'luis.martinez@empresa.es',
        password: 'secreto',
      }),
    );
    return sesion.token;
  }
}

Secuencia observada:

Inicio:                  ENTREGADO
Pulsar sin sesión:       ENVIADO     (al instante)
Tras la respuesta 401:   ENTREGADO   No se pudo cambiar el estado del pedido.
Pulsar con sesión:       ENVIADO

Ejercicio 7: Conservar el valor con linkedSignal

Pagina los empleados sin que la lista desaparezca durante la carga, sin usar resourceFromSnapshots. Esta solución no depende de nada experimental.

Ver solución
// src/app/ultimo-valor/ultimo-valor.ts
import { httpResource } from '@angular/common/http';
import { Component, linkedSignal, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-ultimo-valor',
  template: `
    <button type="button" id="ej7b" (click)="pagina.set(2)">Página 2</button>
    <p id="ej7e">{{ empleados.status() }}</p>
    <ul [class.apagada]="empleados.isLoading()" id="ej7l">
      @for (e of ultimo()?.contenido; track e.empId) {
        <li>{{ e.nombre }}</li>
      }
    </ul>
  `,
})
export class UltimoValor {
  protected readonly pagina = signal(1);

  protected readonly empleados = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: { pagina: this.pagina(), tamano: 3 },
  }));

  protected readonly ultimo = linkedSignal<Pagina<Empleado> | undefined, Pagina<Empleado> | undefined>({
    source: () => (this.empleados.hasValue() ? this.empleados.value() : undefined),
    computation: (actual, anterior) => actual ?? anterior?.value,
  });
}
Página 1:          resolved  Ana Luis Marta
Pulsar página 2:   loading   Ana Luis Marta
Al terminar:       resolved  Pedro Laura Javier

Mientras carga, la fuente vale undefined, de modo que actual ?? anterior?.value conserva lo último que hubo.

Ejercicio 8 (propuesto): Panel que se refresca solo

Con rxResource y timer(0, 5000) más switchMap, consulta /api/estadisticas cada cinco segundos. Comprueba que value cambia con cada emisión, y que al salir de la pantalla dejan de lanzarse peticiones.

Ejercicio 9 (propuesto): EstadoRecurso para defaultValue

Amplía EstadoRecurso para que muestre el esqueleto también con recursos que tienen defaultValue, en los que hasValue() es true durante la carga. Pista: usa status().

Ejercicio 10 (propuesto): Buscador completo

Une todo en la pantalla de empleados: búsqueda con retardo, paginación que vuelve a la página 1, conservación del valor, mensaje si no hay resultados y un botón «Reintentar».


Resumen

Concepto API Idea clave
Recurso resource({ params, loader }) Un valor asíncrono expuesto como señales
Dependencias params Es reactivo; el loader no lo es
Cancelación abortSignal Pásalo a fetch; evita las carreras
Estados status() idle, loading, reloading, resolved, local, error
Lectura segura hasValue(), error() value() lanza en estado de error
Sin cargar undefined en params El recurso queda idle
Recarga reload() Conserva el valor; error() no se borra hasta terminar
HTTP httpResource Sobre HttpClient, con parse, statusCode(), headers()
Observables rxResource({ stream }) Se suscribe y cancela solo
Encadenado ctx.chain(padre) 🆕 Propaga idle, carga y errores
Valores parciales stream resolved llega con el primer valor
Escritura local set, update Estado local; no envía nada al servidor
Optimista set + HttpClient Guarda el anterior para deshacer
Composición snapshot, resourceFromSnapshots Esta última es experimental
Retardo debounced 🆕 Experimental; alternativa con abortSignal
Compartir Servicio y asReadonly() Cada recurso hace su petición
Pruebas TestBed.tick() Antes de expectOne; no whenStable