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 distinguirparamsdeloader - Cancelar cargas obsoletas con
abortSignaly 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
rxResourcecuando la carga es un Observable - Encadenar recursos con
ResourceParamsContexty propagar estados y errores - Recibir valores parciales con
stream - Escribir en un recurso con
setyupdate, 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,rxResourceyHttpClient
🔄 Cambia en Angular 22.0.
resource,httpResourceyrxResourceson 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
cargandoatruey un error a la vez. Cada rama decargardebe 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 uninput(), necesitas además uneffect. - Limpieza al destruir. Si el componente desaparece con una petición en vuelo, también hay que cancelarla.
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.
resourcepasa 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.

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

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 |
Figura 4. Recorrido de un recurso. La reactividad termina en params.
⚠️ Importante: Lo que leas dentro del
loaderno se vigila. Si hacesconst p = this.pagina()dentro delloader, cambiarpaginano lanzará otra carga. En una prueba, unloaderque leía una señal se ejecutó una sola vez aunque esa señal cambió después. Toda dependencia debe pasar porparams.
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:
fetchno lanza una excepción con un 404 o un 500; solo falla si no hay red. Por eso el ejemplo compruebarespuesta.oky lanza su propio error. Sin esa comprobación, el JSON del error se tomaría por un empleado.httpResourcehace 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
paramsse 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 enparams. Si usas la LTS: calcula el parámetro en uncomputedaparte y pásalo comoparams: elcomputedno 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
paramsdevuelve 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 uncomputedcon la opciónequal.
Excepciones en params 🔄 v22.0
🔄 Cambia en Angular 22.0. Si
paramslanza una excepción, el recurso pasa aerrory 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 enparams; comprueba los datos y devuelveundefinedsi 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 |
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=-

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:
loadingborra el valor. Al pasar de 100 a 101,hasValueesfalse: no se muestra a Ana mientras carga Luis, porque serían datos de otra persona.reloadinglo conserva. Con los mismos parámetros, Luis sigue en pantalla mientras se refresca.previous.statusindica 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(), compruebahasValue()oerror(). Ni siquieradefaultValuete protege: el valor por defecto sirve enidleyloading, pero enerrorvalue()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()datruetambién mientras carga, porque el valor por defecto no esundefined. 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, peroerror()sigue devolviendo el error antiguo hasta que termina la carga, yvalue()dadefaultValueoundefined. Si tu plantilla pregunta primero porerror(), el usuario pulsará «Reintentar» y no verá nada. Pregunta primero porisLoading(), como enDetalleEmpleado.
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
undefineddeja inactivo el recurso. Unnulles un parámetro válido y lanzaría elloader. Si tu señal usanull, escribeparams: () => 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.
httpResourcepasa de experimental a estable. Si usas la LTS: se usa igual, importado desde@angular/common/http, pero es experimental. Por debajo usaXMLHttpRequesten lugar defetch, salvo que configuresprovideHttpClient(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,httpResourcelanza 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);
}
}

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:

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 ?? '-';
});
}

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:
parsees 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,
departamentoytextousan la misma URL, y en la red se ven dos peticiones.httpResourceno 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.
rxResourcepasa 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 igualmentestream.
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 enerror.
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
paramsconhasValue()dentro de uncomputed, como enCadenaLts; el hijo queda enidlemientras 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.
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

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

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 sindefaultValueincluyeundefined, aunque en la prácticachainno devuelva tal cosa en este caso. De ahí el?.enempleado?.jefeId. Además, si el padre está enreloading, el hijo pasa aloadingy pierde su valor: refrescar el padre hace parpadear a todos los hijos.
💡 Consejo:
chainlanzaResourceParamsStatus.IDLEoResourceParamsStatus.LOADING. Tú también puedes lanzarlos desdeparamspara forzar esos estados, aunque devolverundefinedsuele ser más claro paraidle.
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
resolvedcon el primer valor, no al terminar, eisLoading()deja de sertrueen ese momento.resourceno 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:
resourceyrxResourceno tienen esta opción;httpResourcesí aprovecha la caché de transferencia deHttpClient, 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:
setsolo cambia el dato en el navegador. No envía nada al servidor, yresourcetampoco sirve para enviarlo: los recursos están pensados para leer. Una carga cancelada a medias no es grave, pero unPOSTcancelado porque cambió una señal sí. Las escrituras se hacen desde un método, conHttpClient.
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:

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

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
Authorizationa 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.
snapshoty el tipoResourceSnapshotpasan 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:
resourceFromSnapshotses 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 simplelinkedSignal.
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

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
loadercon una promesa ligada aabortSignal, como enBuscadorLts, o usadebounceTimede 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, comoempleado. Si procede de una llamada, por ejemplo uninput()que se lee conrecurso(), 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`);
}

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. Conng-template, el contenido solo se crea cuandoEstadoRecursolo 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
injectordel servicio vive tanto como ese servicio. SiempleadoBajoDemandase llama muchas veces, se acumulan recursos: guarda lo devuelto y llama adestroy()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 aflush. La prueba se queda esperando hasta que agota el tiempo:Test timed out in 5000ms.
14. ¿Qué herramienta uso?
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 |