The release of Angular v19, just a few weeks ago, marks a significant milestone in the signal revolution within the framework, with the Input, Model, Output and Signal Queries APIs now officially promoted to stable.
But that's not all! This major version also introduces powerful new tools designed to further advance the signal revolution: the new Resource API.
As the name suggests, this new Resource API is designed to simplify loading asynchronous resources by harnessing the full power of signals!
IMPORTANT: at the time of writing, the new Resource API is still experimental. This means it may change before becoming stable, so use it at your own risk. 😅
Let's dive into how it works and how it simplifies handling async resources!
The Resource API
Most signal APIs are synchronous, but in real-world applications, it is essential to handle asynchronous resources, such as fetching data from a server or managing user interactions in real-time.
This is where the new Resource
API comes into play.
Using a Resource
, you can easily consume an asynchronous resource via signals, allowing you to easily manage data fetching, handle loading states, and trigger a new fetch whenever the associated signal parameters change.
resource( ) function
The easier way to create a Resource
is by using the resource()
function:
import { resource, signal } from '@angular/core';
const RESOURCE_URL = 'https://jsonplaceholder.typicode.com/todos/';
private id = signal(1);
private myResource = resource({
request: () => ({ id: this.id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id),
});
This function accepts a ResourceOptions
configuration object as input, allowing you to specify the following properties:
-
request
: a reactive function that determines the parameters used to perform the request to the asynchronous resource; -
loader
: a loading function that returns aPromise
of the resource's value, optionally based on the providedrequest
parameters. This is the only required property ofResourceOptions
; -
equal
: equality function used to compare theloader
's return value; -
injector
: overrides theInjector
used by theResource
instance to destroy itself when the parent component or service is destroyed.
Thanks to these configurations, we can easily define an asynchronous dependency that will always be consumed efficiently and kept up-to-date.
Resource life cycle
Once a Resource
is created, the loader
function is executed, then the resulting asynchronous request starts:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
Whenever a signal that the request
function depends on changes, the request
function runs again, and if it returns new parameters, the loader
function is triggered to fetch the updated resource's value:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
console.log(myResource.value()); // Prints: { "id": 1 , ... }
id.set(2); // Triggers a request, causing the loader function to run again
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
console.log(myResource.value()); // Prints: { "id": 2 , ... }
If no request
function is provided, the loader
function will run only once, unless the Resource
is reloaded using the reload
method (more below).
Finally, once the parent component or service is destroyed, the Resource
is also destroyed unless a specific injector has been provided.
In such cases, the Resource
will remain active and be destroyed only when the provided injector
itself is destroyed.
Aborting requests with abortSignal
To optimize data fetching, a Resource
can abort an outstanding requests if the request()
computation changes while a previous value is still loading.
To manage this, the loader()
function provides an abortSignal
, which you can pass to ongoing requests, such as fetch
. The request listens for the abortSignal
and cancels the operation if it's triggered, ensuring efficient resource management and preventing unnecessary network requests:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request, abortSignal }) =>
fetch(RESOURCE_URL + request.id, { signal: abortSignal })
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// Triggers a new request, causing the previous fetch to be aborted
// Then the loader function to run again generating a new fetch request
id.set(2);
console.log(myResource.status()); // Prints: 2 (which means "Loading")
Based on this, it's recommended to use the Resource
API primarily for GET
requests, as they are typically safe to cancel without causing issues.
For POST
or UPDATE
requests, canceling might lead to unintended side effects, such as incomplete data submissions or updates. However, if you need similar functionality for these types of requests, you can use the effect()
method to safely manage the operations.
How to consume a Resource
The Resource
API provides several signal properties for its state, that you can easily use directly within your components or services:
-
value
: contains the current value of theResource
, orundefined
if no value is available. As aWritableSignal
, it can be manually updated; -
status
: contains the current status of theResource
, indicating what theResource
is doing and what can be expected from itsvalue
; -
error
: if in the error state, it contains the most recent error raised during theResource
load; -
isLoading
: indicates whether theResource
is loading a new value or reloading the existing one.
Here's an example of how to consume a Resource
within a component:
import { Component, resource, signal } from '@angular/core';
const BASE_URL = 'https://jsonplaceholder.typicode.com/todos/';
@Component({
selector: 'my-component',
template: `
@if (myResource.value()) {
{{ myResource.value().title }}
}
<button (click)="fetchNext()">Fetch next item</button>
`
})
export class MyComponent {
private id = signal(1);
protected myResource = resource({
request: () => ({ id: this.id() }),
loader: ({ request }) =>
fetch(BASE_URL + request.id).then((response) => response.json()),
});
protected fetchNext(): void {
this.id.update((id) => id + 1);
}
}
In this example, the Resource
is used to fetch data from an API based on the value of the id
signal, which can be incremented by clicking a button.
Whenever the user clicks the button, the id
signal value changes, triggering the loader
function to fetch a new item from the remote API.
The UI automatically updates with the fetched data thanks to the signals properties exposed by the Resource
API.
Check the status of a Resource
As mentioned earlier, the status
signal provides information about the current state of the resource at any given moment.
The possible values of the status
signal are defined by the ResourceStatus
enum. Here's a summary of these statuses and their corresponding values:
- Idle =
0
: theResource
has no valid request and will not perform any loading.value()
isundefined
; - Error =
1
: the loading has failed with an error.value()
isundefined
; - Loading =
2
: the resource is currently loading a new value as a result of a change in its request.value()
isundefined
; - Reloading =
3
: the resource is currently reloading a fresh value for the same request.value()
will continue to return the previously fetched value until the reloading operation completes; - Resolved =
4
: the loading is completed.value()
contains the value returned from the loader data-fetching process; - Local =
5
: the value was set locally viaset()
orupdate()
.value()
contains the value manually assigned.
These statuses help track the Resource
's progress and facilitate better handling of asynchronous operations in your application.
hasValue( ) function
Given the complexity of these statuses, the Resource API provides a hasValue()
method, which returns a boolean based on the current status.
This ensures accurate information about the Resource
's status, providing a more reliable way to handle asynchronous operations without relying on the value, which might be undefined
in certain states.
hasValue() {
return (
this.status() === ResourceStatus.Resolved ||
this.status() === ResourceStatus.Local ||
this.status() === ResourceStatus.Reloading
);
}
This method is reactive, allowing you to consume and track it like a signal
.
isLoading( ) function
The Resource
API also provides an isLoading
signal, which returns whether the resource is currently in the Loading
or Reloading
state:
readonly isLoading = computed(
() =>
this.status() === ResourceStatus.Loading ||
this.status() === ResourceStatus.Reloading
);
Since isLoading
is a computed signal, it can be tracked reactively, allowing you to monitor the loading state in real-time using signals APIs.
Resource value as a WritableSignal
The value signal provided by a Resource
is a WritableSignal
, which allows you to update it manually using the set()
and update()
functions:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
console.log(myResource.value()); // Prints: { "id": 1 , ... }
myResource.value.set({ id: 2 });
console.log(myResource.value()); // Prints: { id: 2 }
console.log(myResource.status()); // Prints: 5 (which means "Local")
myResource.value.update((value) => ({ ...value, name: 'Davide' });
console.log(myResource.value()); // Prints: { id: 2, name: 'Davide' }
console.log(myResource.status()); // Prints: 5 (which means "Local")
Note: as you can see, manually updating the
value
of the signal will also set the status to5
, which means "Local", to indicate that the value was set locally.
The manually set value will persist until either a new value is set or a new request is performed, which will override it with a new value:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
console.log(myResource.value()); // Prints: { "id": 1 , ... }
myResource.value.set({ id: 2 });
console.log(myResource.value()); // Prints: { id: 2 }
console.log(myResource.status()); // Prints: 5 (which means "Local")
id.set(3); // Triggers a request, causing the loader function to run again
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
console.log(myResource.value()); // Prints: { "id": 3 , ... }
Note: the
value
signal of the Resource API uses the same pattern of the newLinkedSignal
API, but does not use it under the hood. 🤓
Convenience wrappers methods
To simplify the use of the value
signal, the Resource
API provides convenience wrappers for the set
, update
, and asReadonly
methods.
The asReadonly
method is particularly useful as it returns a read-only instance of the value
signal, allowing access only for reading and preventing any accidental modifications.
You can use this approach to create services that manage and track changes to resource values by exporting a read-only instance of the value
:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
export class MyService {
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id })
});
public myValue = myResource.value.asReadonly();
setValue(newValue) {
// Wrapper of `myResource.value.set()`
myResource.set(newValue);
}
addToValue(addToValue) {
// Wrapper of `myResource.value.update()`
myResource.update((value) => ({ ...value, ...addToValue });
}
}
// Usage of the service in a component or other part of the application
const myService = new MyService();
myService.myValue.set(null); // Property 'set' does not exist in type 'Signal'
myService.setValue({ id: 2 });
console.log(myService.myValue()); // Prints: { id: 2 }
myService.addToValue({ name: 'Davide' });
console.log(myService.myValue()); // Prints: { id: 2, name: 'Davide' }
This will prevent consumers from modifying the value, reducing the risk of unintended changes, improving consistency in complex data management.
Reload or destroy a Resource
When working with asynchronous resources, you may face scenarios where refreshing the data or destroy the Resource
becomes necessary.
To handle these scenarios, the Resource API provides two dedicated methods that offer efficient solutions for managing these actions.
reload( ) function
The reload()
method instructs the Resource
to re-execute the asynchronous request, ensuring it fetches the most up-to-date data:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
myResource.reload(); // Returns true if a reload was initiated
console.log(myResource.status()); // Prints: 3 (which means "Reloading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 5 (which means "Local")
The reload()
method returns true
if a reload is successfully initiated.
If a reload cannot be performed, either because it is unnecessary, such as when the status is already Loading
or Reloading
, or unsupported, like when the status is Idle
, the method returns false
.
destroy( ) function
The destroy()
method manually destroys the Resource
, destroying any effect()
used to track request changes, canceling any pending requests, and setting the status to Idle
while resetting the value to undefined
:
import { resource, signal } from "@angular/core";
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = resource({
request: () => ({ id: id() }),
loader: ({ request }) => fetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
myResource.destroy(); // Returns true if a reload was initiated
console.log(myResource.status()); // Prints: 1 (which means "Idle")
console.log(myResource.value()); // Prints: undefined
After a Resource
is destroyed, it will no longer respond to request changes or reload()
operations.
Note: at this point, while the
value
signal remains writable, theResource
will lose its intended purpose and no longer serves its function, becoming useless. 🙃
rxResource( ) function
Like nearly all signal-based APIs introduced so far, the Resource
API also offers an interoperability utility for seamless integration with RxJS.
Instead of using the resource()
method to create a Promise-based Resource
, you can use the rxResource()
method to use Observables:
import { resource, signal } from "@angular/core";
import { rxResource } from '@angular/core/rxjs-interop';
import { fromFetch } from 'rxjs/fetch';
const RESOURCE_URL = "https://jsonplaceholder.typicode.com/todos/";
const id = signal(1);
const myResource = rxResource({
request: () => ({ id: id() }),
loader: ({ request }) => fromFetch(RESOURCE_URL + request.id)
});
console.log(myResource.status()); // Prints: 2 (which means "Loading")
// After the fetch resolves
console.log(myResource.status()); // Prints: 4 (which means "Resolved")
console.log(myResource.value()); // Prints: { "id": 1 , ... }
Note: the
rxResource()
method is in fact exposed by therxjs-interop
package.
The Observable produced by the loader()
function will consider only the first emitted value, ignoring subsequent emissions.
Thanks for reading so far 🙏
Thank you all for following me throughout this wonderful 2024. 🫶🏻
It has been a year full of challenges, but also very rewarding. I have big plans for 2025 and I can't wait to start working on them. 🤩
I’d like to have your feedback so please leave a comment, like or follow. 👏
Then, if you really liked it, share it among your community, tech bros and whoever you want. And don’t forget to follow me on LinkedIn. 👋😁
Top comments (0)