DEV Community

Cover image for Angular v22.2: Router Resources vs Resolvers β€” Parallel Data Loading Without the Waterfall πŸ”₯πŸš€
Giorgio Galassi
Giorgio Galassi

Posted on AI-assisted

Angular v22.2: Router Resources vs Resolvers β€” Parallel Data Loading Without the Waterfall πŸ”₯πŸš€

Let's be honest: resolvers were never loved, they were tolerated. You wrote one per piece of data, wired it into resolve, and accepted that a parent route's resolver had to finish before the child's could even start. Add two levels of nesting and a slow endpoint, and navigation turns into a waterfall the user watches in real time.

Angular 22.2 ships an alternative built on the API we've been using everywhere else for two years: resource(). Routes can now declare Router Resources, and the router loads them for every matched route at the same time.

⚠️ Availability: Router Resources are @developerPreview 22.2. The API can still change before it goes stable, so try it on a feature branch before you migrate a whole app.

πŸ§ͺ Try it: the companion demo on GitHub runs the same routes twice, once with Router Resources and once with resolvers, and logs when every request starts and ends.

πŸ”§ Turning It On

Router Resources are a router feature, opted into next to the ones you probably already have:

bootstrapApplication(App, {
  providers: [
    provideRouter(
      routes,
      withComponentInputBinding(),
      withRouterResources(),
    ),
  ],
});
Enter fullscreen mode Exit fullscreen mode

withComponentInputBinding() is optional, but keep it: it powers the nicest part of the API.

🧠 Declaring Resources on a Route

A route gets a new resources property. It's a function that receives a context of route signals and returns an object map of resources:

const routes: Routes = [
  {
    path: 'user/:id',
    component: UserProfile,
    resources: (ctx) => {
      const api = inject(UserService);
      return {
        user: resource({
          params: () => ctx.params()['id'],
          loader: ({ params: id, abortSignal }) => api.getUser(id, abortSignal),
        }),
        activity: nonBlocking(
          resource({
            params: () => ctx.params()['id'],
            loader: ({ params: id, abortSignal }) => api.getActivity(id, abortSignal),
          }),
        ),
      };
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing here:

  • It runs in an injection context, so inject() works directly, no factory function needed.
  • ctx exposes params, queryParams, fragment and data, each one a Signal.
  • Any Resource works: resource(), rxResource(), or your own implementation.
  • It can be async and return a Promise, handy for a dynamic import() of the API client. One catch: the injection context ends at the first await, so capture inject(Injector) before it and pass it as injector to each resource, or resource() throws NG0203.
  • Forward the abortSignal to your requests: when the router supersedes or rolls back a navigation, the pending request is aborted instead of finishing for nothing.

At this point, it's worth noting a detail from the official guide: read ctx.params()['id'], not ctx.params(). The router creates a fresh params object on every navigation, so depending on the whole object would reload the resource even when the id didn't change.

⚠️ On 22.2.3 that isn't enough yet. A plain params function still reruns the loader on every navigation of the route, even one that only changes a query param or the fragment, with the same id. The cause is in resource() itself, and the fix is merged on the 22.2 branch but not released as of 22.2.3. Until it ships, pass a computed() instead of a function: const id = computed(() => ctx.params()['id']);, then params: id.

⚑ Blocking vs Non-Blocking

This is the real upgrade over resolvers. By default a Router Resource is blocking: navigation waits until it resolves, exactly like a resolver did. Wrap it in nonBlocking() and the navigation completes immediately, while the resource keeps loading in the background.

So the profile above waits for user, because a profile page without a user is meaningless, but it doesn't hold the user hostage while the activity feed loads.

And unlike resolvers, which run one route at a time from parent to child, resources across all matched routes load concurrently. The waterfall is gone without you having to restructure anything.

🎯 Reading the Data in the Component

With withComponentInputBinding(), each resource lands on a component input with the same name. The blocking/non-blocking split shows up in the types:

@Component({
  template: `
    <h1>{{ user().name }}</h1>

    @if (activity().isLoading()) {
      <p>Loading activity…</p>
    } @else if (activity().error()) {
      <p>Activity is unavailable right now.</p>
    } @else if (activity().hasValue()) {
      <app-activity-list [items]="activity().value()" />
    }
  `,
})
export class UserProfile {
  user = input.required<User>();                       // blocking β†’ the value
  activity = input.required<Resource<Activity[]>>();   // non-blocking β†’ the resource
}
Enter fullscreen mode Exit fullscreen mode

A blocking resource binds its value: by the time the component exists, the data is there. A non-blocking resource binds the whole Resource, because the component has to render the loading and error states itself. Don't skip the error() branch: reading value() on a resource in the error state throws, which is why the template checks hasValue() before reading it.

If a key collides, resources win over data (resolvers included), which win over path params, which win over query params.

Without input binding, the same map is available as ActivatedRoute.resources. The router hands them out read-only: you can read the signals and call reload(), but set() and update() aren't there.

πŸ§ͺ Try It Yourself

The demo on GitHub puts both versions of the same routes side by side in app.routes.ts. Run it, pick a user under Router Resources, then the same user under Resolvers, and compare the timelines. The checkboxes make the blocking or the non-blocking request fail.

git clone https://github.com/giorgiogalassi/angular-article-demos.git
cd angular-article-demos/demos/2026-10-01-angular-router-resources
npm ci && npm start
Enter fullscreen mode Exit fullscreen mode

πŸ” Reload Without Re-Navigating

The second big difference from resolvers: refreshing resolver data meant navigating again, rerunning guards and rematching routes. A Router Resource just reloads:

private userRes = inject(ActivatedRoute).resources?.['user'];

refresh() {
  this.userRes?.reload();
}
Enter fullscreen mode Exit fullscreen mode

reload() returns false while a navigation is in flight or the router is recovering from a cancelled one, so you won't race the router.

Or refresh reactively: update any signal the params function reads, and the loader runs again.

During navigation the router also freezes the exposed resources. A reused component keeps showing the previous user until the next one resolves, with no flash of a loading state in between. If the navigation is cancelled, it holds the previous snapshot until the resource settles back.

πŸ’₯ When Loading Fails

Errors follow the same split:

Case What happens What to do
Blocking resource fails during navigation The router cancels the navigation and emits NavigationError, like a resolver that threw Handle it where you already handle navigation errors
Non-blocking resource fails The navigation completes; the failure is on the resource's error() Render the error state in the template
You need a redirect β€” Throw a RedirectCommand inside the loader
Blocking resource fails on a later reload() The input has no value to receive, so reading it throws on the page that's already showing Wrap <router-outlet> in a @boundary; its retry reloads the failed resource before calling $reset()

The last row isn't in the docs, but the companion demo shows it. The docs promise no error state during navigation; a later reload() is outside that promise, and resetting the boundary alone re-renders against a resource that's still in error.

The last two cases are where the template has to cope with failure, and Angular 22.2 shipped the right tool for that the same week: @boundary. The two features were clearly designed to meet in the middle, and that's what the companion article covers.

βœ… Should You Migrate?

Not by rewriting every resolver tomorrow: this is developer preview. But the next time you add data to a route, especially one nested under a parent that already resolves something, reach for resources instead of resolve. The rules of thumb:

Use For
Blocking (the default) Data the page can't render without
nonBlocking() Everything the user can wait for while looking at the page
reload() Refreshing, instead of re-navigating

Resolvers modelled data loading as a step navigation has to get through. Router Resources model it as state the route owns, which is the same move httpResource() made for HTTP calls.


If you want to go deeper on the resource APIs this is built on, check out my previous articles:


If you found this helpful, follow me here and on LinkedIn for more deep dives into Angular, signals and modern frontend development.

I also send the longer, more opinionated version of this kind of thing to a small list β€” reading the source, and what it changes about how you build. Subscribe here.

See you in the next one! πŸ€™πŸ»
β€” G.

Top comments (2)

Collapse
 
vektratool profile image
VektraTool •

The blocking vs nonBlocking split in the input types is really neat. The component's type tells you whether you're getting a value or a resource. Thanks for flagging the computed() workaround for 22.2.3, that would have confused me for a while. How do non-blocking resources behave with SSR? Does the server render wait for them, or do they always start loading on the client after hydration?

Collapse
 
ggalassi profile image
Giorgio Galassi •

Very good question!

I think the answer, or part of, may be this one: angular.dev/guide/signals/resource...

"When an application renders on the server, a resource loader runs once to produce the initial HTML. During hydration, the browser normally runs the same loader again." and Angular saves the result into a TransferState.

Adding nonBlocking into the picture i can assume that it would behave in the same way, while i can assume that the default (blocking) would instead waiting for the API to return a response before moving into rendering the page.

It's worth a test.