Skip to content

Route-level resources: feedback on previous-value semantics, parallel loads, typed errors, and input type safety #70919

Description

@RomainDood

Which @angular/* package(s) are relevant/related to the feature request?

router, core

Description

Thank you for shipping route-level resources. This is something the community has been asking for, and having a signal-based alternative to resolvers — with blocking and non-blocking resources, reactive params, and reload() — is a real step forward. The notes below are gathered in one issue because they all come from using that feature, and because a few follow-ups would make it more accessible and more expressive.

Component resources and route resources do not reload the same way.

A component-level resource() is reactive: when its params change, the loader runs again. By default it does not keep the previous value. Keeping that value across a reload is an explicit composition choice.

A route-level resource behaves differently. routerResource freezes the current snapshot at NavigationStart and keeps serving it until the navigation settles (NavigationEnd / NavigationSkipped, or until rollback recovery finishes). The page the user is already looking at stays on screen while the resource for the new params is loading.

That frozen snapshot is effectively "keep the previous value while navigating". It is a reasonable UX for avoiding a blank page or a loading flash, but it is not the default contract of resource(). If you have not read the router resource docs, there is no signal in the API that a resource attached to a route will preserve the previous result instead of exposing a fresh loading state. It is easy to build a mental model from components and then be surprised by routes.

Failures are untyped.

Today a resource failure is an Error (or unknown) on the error channel. A lot of failures are domain results, not infrastructure exceptions: the user was not found, the caller is not allowed to perform the action, the connection was lost. The route cannot choose a behavior per failure (a specific navigation, a retry, a dedicated empty or forbidden state) when every failure collapses into one error().

Blocking and non-blocking resources are not distinguished at the component boundary.

Whether a route resource is blocking or nonBlocking(), the value is retrieved from the component the same way (an input bound from the route). There is no compile-time error if the component expects the resolved value and the route passed the Resource, or the reverse. The mismatch shows up at runtime. Route resources are a new boundary between the router and the component, and an unsound boundary will be copied into a lot of applications.

Proposed solution

1. Parallel resources per params, as the model behind route resources.

Allow distinct resources to load in parallel, one per params identity, instead of reusing a single resource and deciding whether to keep or drop its previous value.

On the first visit, one resource loads. When the user navigates to another id, a second resource loads for that id. Both stay addressable. There is no hidden "previous value" policy, because each request has its own resource. The route layer would not need a special "freeze the previous snapshot" complement: each navigation would address the resource for its own params.

The same primitive also covers cases that are awkward with a single replacing resource:

  • pagination (page 1 stays available while page 2 loads)
  • signal forms, where several validators may need to run in parallel rather than cancelling each other

This is a behavior I need regularly and that Angular does not expose yet, so I have been building it outside the framework. A write-up of the shape I have in mind:

https://dev.to/romain_geffrault_10d88369/resourcebygroup-the-next-angular-resource-59gf

A similar utility (server state, parallel calls selected by id):

const userId = signal<number | undefined>(undefined);

const { userQuery } =
  yield *
  query('userQuery', {
    params: userId,
    identifier: (id) => id,
    loader: function* ({ params }) {
      return yield* CraftHttpClient.get(({ response }) => ({
        url: `/api/users/${params}`,
        success: response<User>(),
      }));
    },
  });

userId.set(1);
userId.set(2);

userQuery.select('1').value(); // user 1
userQuery.select('2').value(); // user 2

https://craft-ts.github.io/craft/guide/state/server-state.html

TanStack Query, which is specialized in server state, already handles this well: each query key is an independent cache entry, so two param sets load in parallel and can be read independently.

2. Typed errors (error as value).

Let a resource resolve to either the success value or a typed failure. Domain failures (not found, forbidden, offline) stay distinguishable from infrastructure exceptions. From the route, each typed failure can map to a navigation, a retry, or a dedicated state.

3. A typed boundary between the route resource and the component input.

Make blocking and non-blocking resources incompatible at compile time, so a component cannot read one as the other. Tightening this boundary is a large project, and it is a natural place to introduce the type-safe dependency injection Angular still lacks: the token carries its type, and the compiler checks the consumer. The same guarantee would apply to route resources first, and eventually to DI itself.

Alternatives considered

An explicit composition helper, without changing resource().

A helper dedicated to the route case would make "keep previous" opt-in and visible:

user: keepPreviousValueWhileNavigating(resource({
  params: () => ctx.params()['id'],
  loader: ({params: id}) => userService.getUser(id),
}))

An ESLint rule could require route resources factories to go through that helper. The helper could also return a branded type, so a resource registered on a route is only accepted if it was produced by that utility. A plain resource() or nonBlocking(resource(...)) would then fail at compile time instead of relying on documentation.

This covers the surprising reload semantics, and it leaves the default resource() contract unchanged. It does not remove the need for a special route-only behavior, and it does not help pagination or parallel validators. Parallel resources (proposed solution 1) make this helper unnecessary for the route case. The helper can still exist for the cases where keeping the previous value of a single resource is what the developer wants.

Leaving the frozen snapshot as an implicit router behavior.

The current routerResource freeze is coherent for avoiding a loading flash, and it can stay undocumented beyond the router guide. The cost is the split mental model between component resources and route resources described above.

Happy to refine the API sketches or share more of the parallel-resource experiments if that is useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions