Which @angular/* package(s) are relevant/related to the feature request?
core
Description
Why an issue and not a PR? The implementation is done and tested: one commit on feat/lazy-resource, passing the repo's own suites (details below). But #58422 was closed on a deliberate design position, and asking to reverse a team decision inside a PR review did not feel right. If the team is open to reconsidering, I will open the PR immediately.
Context. For loading async data into a screen today, we have:
resource() and rxResource(): the params describe what to fetch, a loader fetches it, and templates read the result through value(), status() and the other signals. Loading is eager by design: it starts at construction, and runs again whenever the params change, whether something displays the data or not.
@defer: it defers a block of the view. The code chunk loads and the block renders on its trigger; it says nothing about data.
As far as I know, nothing today defers the data itself.
Problem. Not everything a screen declares is needed right away: detail panels, tabs, previews. With eager-only resources, we keep facing the same two options:
- fetch everything up front, and pay for requests that may never be looked at;
- or push each request down into a component that only exists once the UI shows it, behind an
@if or inside a @defer block, so that its lifecycle plays the part of the missing laziness.
// this component exists to defer one fetch
class UserCard {
readonly user = rxResource({
params: () => 1,
stream: ({ params }) => api.user(params),
});
}
<!-- created: fetches. destroyed: forgets. shown again: fetches again. -->
@if (showCard()) {
<user-card />
}
The second option works, and it is what we do today, but it has real costs. The value dies with the component, so every recreation fetches again. Data fetching scatters down the tree, away from the routes and services that own the screen. And some components exist for no other reason than wrapping a request. What seems to be missing is a resource that can be declared where the data belongs, yet does not evaluate until something actually reads it.
Prior issue. #58422 asked for a lazy option and was closed with a clear position: render-driven data fetching invites request waterfalls, and data is better lifted up to routes. I agree with that concern. My point is that the waterfalls are already here, because deferring a fetch without a lazy primitive means tying it to a component lifecycle, which is render-driven fetching in its most fragile form. A lazy resource pulls in the opposite direction:
- the declaration moves up, next to the rest of the screen's data;
- the trigger is the first read, and it fires only once;
- the loaded value outlives its readers, so showing the same UI again does not refetch.
The waterfall the issue worried about has nowhere to repeat. And since the option is opt-in, eager resources are left strictly untouched. It also composes with @defer rather than competing with it: the deferred view brings the code, the lazy resource in the parent brings the data, and neither pays before the other needs it.
Proposed solution
readonly user = resource({
lazy: true, // the only new line
params: () => this.userId(),
loader: ({ params }) => fetchUser(params),
});
// rxResource({ lazy: true, ... }) — inherited through its options, no other change
- The first read of any of its signals starts the load, and nothing else does. That includes
value(), status(), hasValue(), and reads made inside untracked() (otherwise @if (r.hasValue()) could stay false forever: nothing reactive changes, so no change detection cycle comes back to ask again; a non-waking peek() would be a natural follow-up).
- While asleep,
params changes are tracked but never fetched; the first read uses the latest value.
- Writes never wake it:
set() before any read goes local without fetching, and reload() defers the load to the next read.
- Once awake, it behaves exactly like an eager resource: same statuses, errors, cancellation and SSR stability. Combining
lazy with id is documented as discouraged, since the hydration window has usually closed by the first read.
This is implemented and tested. One commit on my fork: feat/lazy-resource — 129 lines in resource.ts (a reactive pull node replaces the load effect when lazy is set), 15 in api.ts, a 500-line spec, and documentation. On that branch, //packages/core/test/resource passes 91/91 (the new lazy suite plus every pre-existing spec unchanged), rxjs-interop passes 76/76, and the public API goldens are green with the repo's own tooling. I would be happy to open the PR if this can be reconsidered.
There is also an interactive version of this proposal, where every demo runs that exact diff (applied to the published @angular/core bundle via patch-package) with the requests visible in a per-demo network log:
Alternatives considered
Before proposing a change to core, I tried to obtain these semantics from userland, on top of the public API. To compare the attempts I wrote a parameterized suite describing 39 observable behaviors of a lazy resource: the sleeping rules above, plus everything a native resource must keep doing once awake (statuses, errors, cancellation, SSR stability).
| strategy |
lines |
contract |
| gate the params on a "shown" flag |
~60 |
18/39 |
reimplement as new functions (lazyResource, lazyRxResource) |
~370 |
39/39 |
| the native option (this proposal) |
~90 |
by construction |
Gating the params on a "shown" flag is the workaround commonly found in applications; it defers the first load but misses most of the sleeping rules. I also tried driving core's private loadEffect: it gets much closer, but stalls at 34/39 and depends on two private fields, which is not something I would want anyone to ship. The only userland shape that passes the whole suite is a reimplementation of the resource machinery beside core (gist, and it is what I use today), about 370 lines that have to follow core on every release. The same semantics fits in about 90 lines inside ResourceImpl, and that difference is why I am proposing the option upstream.
Which @angular/* package(s) are relevant/related to the feature request?
core
Description
Context. For loading async data into a screen today, we have:
resource()andrxResource(): theparamsdescribe what to fetch, a loader fetches it, and templates read the result throughvalue(),status()and the other signals. Loading is eager by design: it starts at construction, and runs again whenever the params change, whether something displays the data or not.@defer: it defers a block of the view. The code chunk loads and the block renders on its trigger; it says nothing about data.As far as I know, nothing today defers the data itself.
Problem. Not everything a screen declares is needed right away: detail panels, tabs, previews. With eager-only resources, we keep facing the same two options:
@ifor inside a@deferblock, so that its lifecycle plays the part of the missing laziness.The second option works, and it is what we do today, but it has real costs. The value dies with the component, so every recreation fetches again. Data fetching scatters down the tree, away from the routes and services that own the screen. And some components exist for no other reason than wrapping a request. What seems to be missing is a resource that can be declared where the data belongs, yet does not evaluate until something actually reads it.
Prior issue. #58422 asked for a
lazyoption and was closed with a clear position: render-driven data fetching invites request waterfalls, and data is better lifted up to routes. I agree with that concern. My point is that the waterfalls are already here, because deferring a fetch without a lazy primitive means tying it to a component lifecycle, which is render-driven fetching in its most fragile form. A lazy resource pulls in the opposite direction:The waterfall the issue worried about has nowhere to repeat. And since the option is opt-in, eager resources are left strictly untouched. It also composes with
@deferrather than competing with it: the deferred view brings the code, the lazy resource in the parent brings the data, and neither pays before the other needs it.Proposed solution
value(),status(),hasValue(), and reads made insideuntracked()(otherwise@if (r.hasValue())could stay false forever: nothing reactive changes, so no change detection cycle comes back to ask again; a non-wakingpeek()would be a natural follow-up).paramschanges are tracked but never fetched; the first read uses the latest value.set()before any read goeslocalwithout fetching, andreload()defers the load to the next read.lazywithidis documented as discouraged, since the hydration window has usually closed by the first read.This is implemented and tested. One commit on my fork:
feat/lazy-resource— 129 lines inresource.ts(a reactive pull node replaces the load effect whenlazyis set), 15 inapi.ts, a 500-line spec, and documentation. On that branch,//packages/core/test/resourcepasses 91/91 (the new lazy suite plus every pre-existing spec unchanged),rxjs-interoppasses 76/76, and the public API goldens are green with the repo's own tooling. I would be happy to open the PR if this can be reconsidered.There is also an interactive version of this proposal, where every demo runs that exact diff (applied to the published
@angular/corebundle via patch-package) with the requests visible in a per-demo network log:Alternatives considered
Before proposing a change to core, I tried to obtain these semantics from userland, on top of the public API. To compare the attempts I wrote a parameterized suite describing 39 observable behaviors of a lazy resource: the sleeping rules above, plus everything a native resource must keep doing once awake (statuses, errors, cancellation, SSR stability).
lazyResource,lazyRxResource)Gating the params on a "shown" flag is the workaround commonly found in applications; it defers the first load but misses most of the sleeping rules. I also tried driving core's private
loadEffect: it gets much closer, but stalls at 34/39 and depends on two private fields, which is not something I would want anyone to ship. The only userland shape that passes the whole suite is a reimplementation of the resource machinery beside core (gist, and it is what I use today), about 370 lines that have to follow core on every release. The same semantics fits in about 90 lines insideResourceImpl, and that difference is why I am proposing the option upstream.