DEV Community

Route Resources in Angular: what the Router actually does with your Signal under the hood

Introduction

Route resolvers have been around in Angular for years, and they do their job, but they come with a price you pay on every navigation: they run one after the other, parent to child, and they can't be refreshed without triggering a full navigation. Angular now ships an alternative built directly on Signals, exposed through the router as a resources route property. The official guide covers the "how to use it" part pretty well: https://next.angular.dev/guide/routing/data-fetching-with-resources.

What the guide doesn't show you is what happens between the moment you write resource({...}) in your route config and the moment your component receives a value. There's an entire file dedicated to that, router_resource.ts.
This article is what I found in there (it's a depth technical article ): how blocking works, why your UI doesn't flash a loading skeleton when you navigate between two similar pages, and where a navigation cancellation can leave you stuck if you don't understand the reload guard.

I'll assume you already know what a resource() is (a value(), an isLoading(), an error(), a status(), and a reload(), all as signals). If not, go read the Resource API guide first, this article builds on top of it.

Turning it on

Route resources are gated behind a feature you have to opt into, withRouterResources():

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding, withRouterResources } from '@angular/router';
import { routes } from './app.routes';
import { App } from './app';

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

Notice withComponentInputBinding() next to it. It's not mandatory to make resources work, but without it you'll have to reach into ActivatedRoute.resources manually instead of getting your data as a plain component input(). In practice you'll almost always want both together.

Defining resources on a route

A route now accepts a resources property, a function returning a map of Resource instances:

import { inject } from '@angular/core';
import { resource } from '@angular/core';
import { Routes } from '@angular/router';
import { ProductService } from './product.service';

export const routes: Routes = [
  {
    path: 'products/:sku',
    component: ProductPage,
    resources: (ctx) => {
      const productService = inject(ProductService);
      return {
        product: resource({
          params: () => ctx.params()['sku'],
          loader: ({ params: sku }) => productService.getBySku(sku),
        }),
      };
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

ctx here is a ResourceContext, giving you params, queryParams, data, and a static snapshot, all as signals. The resources function itself runs in an injection context, which is why inject(ProductService) works directly at the top, without a factory wrapper.

One detail that will bite you if you skip it: pass the primitive you actually need to params, not the whole object. params: () => ctx.params() looks harmless, but the router creates a fresh params object on every navigation, so the resource sees a new reference every time and reloads even when sku didn't change. params: () => ctx.params()['sku'] fixes that, because now the resource only reacts to the primitive value.

resources can also be async, if you need a dynamic import before wiring things up:

resources: async (ctx) => {
  const { fetchProductBySku } = await import('./product-api');
  return {
    product: resource({
      params: () => ctx.params()['sku'],
      loader: ({ params: sku }) => fetchProductBySku(sku),
    }),
  };
},
Enter fullscreen mode Exit fullscreen mode

Any Resource implementation is accepted, not just resource(). If your team is still RxJS-heavy, rxResource() works the same way, swapping loader for stream:

import { rxResource } from '@angular/core/rxjs-interop';

resources: (ctx) => ({
  product: rxResource({
    params: () => ctx.params()['sku'],
    stream: ({ params: sku }) => productService.getBySku$(sku),
  }),
}),
Enter fullscreen mode Exit fullscreen mode

Blocking vs non-blocking, and what actually gets bound to your input

By default every resource is blocking: the router waits for it before activating the route. That has a nice consequence for your component, the input type is the resolved value T, not Resource<T>, because your component never sees a loading or error state, the router already handled that for you.

If you don't want to block navigation, wrap the resource with nonBlocking():

import { Resource, resource } from '@angular/core';
import { Routes, nonBlocking } from '@angular/router';

export const routes: Routes = [
  {
    path: 'products/:sku',
    component: ProductPage,
    resources: (ctx) => ({
      product: nonBlocking(
        resource({
          params: () => ctx.params()['sku'],
          loader: ({ params: sku }) => productService.getBySku(sku),
        }),
      ),
      reviews: resource({
        params: () => ctx.params()['sku'],
        loader: ({ params: sku }) => reviewsService.getForProduct(sku),
      }),
    }),
  },
];
Enter fullscreen mode Exit fullscreen mode

Here product won't block navigation, reviews will. Your component now has to handle both shapes at once:

@Component({
  template: `
    @if (product().isLoading()) {
      <p>Loading product…</p>
    } @else if (product().hasValue()) {
      <h1>{{ product().value().name }}</h1>
    }
    <app-reviews [reviews]="reviews" />
  `,
})
export class ProductPage {
  product = input.required<Resource<Product>>();
  reviews = input.required<Review[]>();
}
Enter fullscreen mode Exit fullscreen mode

nonBlocking() itself is trivial, it just tags the resource with an internal symbol so the router knows to keep the wrapped Resource<T> object as-is instead of unwrapping it:

export function nonBlocking<T, R extends Resource<T>>(res: R): R {
  (res as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] = false;
  return res;
}
Enter fullscreen mode Exit fullscreen mode

The actual unwrapping for blocking resources happens in createResourceOutletBindingEffects, in the same file. It uses reflectComponentType to read the component's declared inputs, then for every input name that matches a key in route.resources and whose BLOCKING_SYMBOL isn't explicitly false, it sets up an effect() that calls componentRef.setInput(templateName, resource.value()):

for (const {templateName} of mirror.inputs) {
  const resource = route.resources?.[templateName];
  if (!resource || !(resource as InternalRouterResource)[BLOCKING_SYMBOL]) {
    continue;
  }
  const effectRef = effect(() => {
    componentRef.setInput(templateName, resource.value());
  }, {injector: componentRef.injector});
  createdEffects.push(effectRef);
  handledKeys.push(templateName);
}
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out here. First, the matching is done by input name against the resource map key, so product: resource({...}) only reaches an @Input() (or input()) literally named product.

Rename one without the other and the binding silently stops working, there's no compile-time link between the two.

Second, handledKeys is returned to the caller so that the router's standard data-binding subscription (RoutedComponentInputBinder) skips those keys. Non-blocking resources are never added to handledKeys, they go through that standard data stream instead, bound as the full Resource object.

What routerResource() actually wraps

Every resource you hand to the resources map gets passed through routerResource() internally before the router uses it. This is the function doing the heavy lifting:

export function routerResource<T>(source: Resource<T>): Resource<T> & {reload(): boolean} {
  const injector = inject(Injector);
  const router = injector.get(Router);
  const {snapshot: snapshotSignal, frozenSnapshot} = createTransactionalSnapshot(source, router, injector);
  const res = resourceFromSnapshots(snapshotSignal) as unknown as InternalRouterResource<T>;
  res[SOURCE_RESOURCE_SYMBOL] = source;
  res[BLOCKING_SYMBOL] = (source as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] !== false;
  // ...reload wiring omitted here, see below
  return res;
}
Enter fullscreen mode Exit fullscreen mode

It doesn't touch your loader, your params function, or your original resource at all. It reads your resource's snapshot() signal (a ResourceSnapshot, the internal shape carrying status/value/error together) through createTransactionalSnapshot, and rebuilds a brand-new Resource from that snapshot stream using resourceFromSnapshots. Your original resource is kept around under SOURCE_RESOURCE_SYMBOL, purely so reload() can delegate to it later. Everything the router hands to your component is this wrapper, not your original resource instance.

The frozen snapshot: why navigating doesn't flash a loading spinner

This is the part I found genuinely clever. When you navigate from /products/1 to /products/2, both routes resolve to the same component and the same product resource key, just with a different sku param. Naively, that resource would immediately flip to isLoading() the moment the param changes, and your component would render a loading skeleton for a split second before the new product shows up. The router avoids that by freezing the resource's snapshot for the duration of the navigation.

createTransactionalSnapshot subscribes to router.events and reacts to five event types:

const sub = router.events.subscribe((e) => {
  if (e instanceof NavigationStart) {
    isRollbackRecoveryPending.set(false);
    if (frozenSnapshot() === null) {
      frozenSnapshot.set(source.snapshot());
    }
  } else if (e instanceof NavigationEnd) {
    frozenSnapshot.set(null);
    isRollbackRecoveryPending.set(false);
  } else if (e instanceof NavigationSkipped) {
    if (frozenSnapshot() !== null) {
      isRollbackRecoveryPending.set(true);
    }
  } else if (e instanceof NavigationCancel || e instanceof NavigationError) {
    const isRollback =
      e instanceof NavigationError ||
      (e instanceof NavigationCancel &&
        e.code !== NavigationCancellationCode.SupersededByNewNavigation &&
        e.code !== NavigationCancellationCode.Redirect);
    if (!isRollback) return;
    isRollbackRecoveryPending.set(true);
  }
});
Enter fullscreen mode Exit fullscreen mode

And the exposed snapshot signal is simply:

snapshot: computed(() => frozenSnapshot() ?? source.snapshot()),
Enter fullscreen mode Exit fullscreen mode

So the flow for a plain, successful navigation is: NavigationStart fires, the router grabs whatever the source resource's snapshot currently is (the old product, already resolved) and locks it in frozenSnapshot. From that point on, even though the underlying resource has already started loading the new sku behind the scenes, your component keeps rendering the frozen, old value — no isLoading flicker, no jump to an empty state. When NavigationEnd fires, the freeze is lifted, frozenSnapshot goes back to null, and the computed falls through to source.snapshot(), which by then holds the new product.

There's a subtlety in that if (frozenSnapshot() === null) guard on NavigationStart. If a second navigation starts while the first hasn't finished (you click fast between two product cards), the router won't re-freeze on top of an existing freeze, it keeps the original snapshot from before the first navigation began. That's intentional, it prevents freezing an intermediate, already-stale value.

Rollback recovery: the part almost nobody thinks about

Here's a scenario the guide only mentions briefly but the source code handles explicitly. Say a canActivate guard rejects a navigation to /products/2. The router cancels it and rolls the URL and route params back to /products/1. That rollback is itself a change in the params signal your resource depends on, from 2 back to 1, so your resource, doing exactly what it's supposed to, fires a new load for sku: 1 — data it already had, and had already shown a second ago.

Without extra handling, that would mean: user clicks, sees the current product freeze for a moment (NavigationStart), sees the guard reject it, then sees a loading flash for the product they were already looking at. The code guards against exactly that with isRollbackRecoveryPending:

} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
  const isRollback =
    e instanceof NavigationError ||
    (e instanceof NavigationCancel &&
      e.code !== NavigationCancellationCode.SupersededByNewNavigation &&
      e.code !== NavigationCancellationCode.Redirect);
  if (!isRollback) return;
  isRollbackRecoveryPending.set(true);
}
Enter fullscreen mode Exit fullscreen mode

Cancellations caused by a newer navigation superseding this one, or by an internal redirect, are not treated as a rollback (the frozen snapshot gets released normally through the navigation that superseded it). Everything else, guard rejections, NavigationError, is treated as a rollback, and the frozen snapshot is kept alive until the resource has actually settled again:

effect(() => {
  if (isRollbackRecoveryPending() && !source.isLoading()) {
    isRollbackRecoveryPending.set(false);
    frozenSnapshot.set(null);
  }
}, {injector});
Enter fullscreen mode Exit fullscreen mode

The freeze only lifts once source.isLoading() is false again, which in the rollback case happens once the resource has re-fetched (or, if it's cached, re-resolved instantly) the old param value. From the user's perspective, nothing happened, the product they were looking at stayed exactly as it was, even though under the hood a full reload was silently triggered and absorbed.

Why reload() sometimes just returns false

Route resources expose .reload(), but it's not a direct passthrough to your original resource's .reload(). It's guarded:

res.reload = function (): boolean {
  if (frozenSnapshot() !== null) {
    return false;
  }
  return (source as any).reload();
};
Enter fullscreen mode Exit fullscreen mode

If you call .reload() while a navigation is pending, or while the router is in the middle of a rollback recovery, the call is rejected and returns false, your original resource's reload() is never invoked. The reasoning tracks with everything above: during those windows the router is already managing the resource's lifecycle for you, and a manual reload sneaking in would fight with that transition tracking, possibly unfreezing something the router is deliberately keeping frozen. If your source resource doesn't implement reload at all (a custom Resource that only has loader, say), res.reload is just () => false unconditionally.

This is worth checking for in your own code. If you wire a refresh button to userResource?.reload() and nothing happens, the boolean return value is telling you exactly why, it's just easy to ignore a boolean you don't check.

@Component({
  template: `<button (click)="refresh()">Refresh</button>`,
})
export class ProductPage {
  private productResource = inject(ActivatedRoute).resources?.['product'];

  refresh() {
    const didReload = this.productResource?.reload();
    if (!didReload) {
      console.warn('Reload skipped, a navigation transition is in progress.');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Redirecting from inside a loader

Blocking resources can redirect before the route ever activates, by throwing a RedirectCommand:

import { inject, resource } from '@angular/core';
import { RedirectCommand, Router } from '@angular/router';

resources: (ctx) => {
  const router = inject(Router);
  return {
    product: resource({
      params: () => ctx.params()['sku'],
      loader: async ({ params: sku, abortSignal }) => {
        const product = await productService.getBySku(sku, { signal: abortSignal });
        if (!product) {
          throw new RedirectCommand(router.parseUrl('/not-found'));
        }
        return product;
      },
    }),
  };
},
Enter fullscreen mode Exit fullscreen mode

Note the abortSignal in the loader arguments, forward it into your fetch call. When the router supersedes a navigation or rolls it back, it aborts the in-flight request through that signal instead of letting an orphaned request finish and update state nobody asked for anymore.

Where this gets uncomfortable

Everything above is solid engineering, but a few things are worth flagging before you rely on this in production.

It's a developer preview API (22.2 at the time of writing). The shapes of ResourceContext, nonBlocking, and the internal snapshot mechanism can change between minors. If you build a design system component library around route.resources, expect to revisit it.

Blocking-resource errors kill the entire navigation, not just the affected component. If you have three parallel blocking resources on a route and one of them throws, the router cancels the whole navigation and fires NavigationError, even if the other two resolved fine and their data would have been perfectly usable. There's no equivalent of a partial activation, you either get every blocking resource or none of them. nonBlocking() sidesteps this, but only if you're willing to accept the full Resource<T> object as your input type instead of the unwrapped value.

The input binding is name-matching, not type-checked. createResourceOutletBindingEffects matches on templateName between your resources map and your component's declared inputs. Rename an input, rename a resource key, or introduce an alias, and the binding breaks silently. TypeScript won't catch this for you, you'll find out at runtime when the input stays undefined.

Manual reload has a silent failure mode. As shown above, .reload() returns false during transitions and rollback recovery instead of throwing or queuing the request. That's the right call for correctness, but it means a "refresh" button clicked at the wrong moment does nothing, and your only way to notice is checking the boolean.

Params identity is a footgun the type system won't warn you about. Passing ctx.params() instead of ctx.params()['id'] compiles fine and works fine in your first manual test, then quietly reloads on every navigation once you actually have more than one route parameter changing shape. This is exactly the kind of bug that survives a demo and shows up three sprints later as "why does this refetch on every click."

Testing route resources properly means testing through the router. Because the freezing and rollback logic lives on router.events subscriptions tied to NavigationStart / NavigationEnd / NavigationCancel, a shallow component test that stubs your service won't exercise any of that behavior. You need a RouterTestingHarness (or equivalent) actually driving navigations to see the freeze/unfreeze cycle at all, plain TestBed.createComponent won't cut it.

Wrapping up

Route resources solve a real problem, resolvers force a sequential waterfall and can't be refreshed without a full renavigation. What the docs describe as "the router masks loading states during pending navigations" is, under the hood, a computed signal falling back between a frozen snapshot and the live one, driven by five router events and one small state machine for rollback recovery. It's not magic, it's a resource wrapper subscribing to navigation lifecycle events and freezing a value at the right moment, then releasing it at the right moment.

If you're adopting this feature now, keep it away from anything load-bearing until it graduates out of developer preview, and keep an eye on that reload() return value.

Top comments (0)