Route-level data

Fetching at construction time or ngOnInit means the component mounts, then constructs, then asks for data, three round trips before the request even leaves. Route-level data moves the fetch to the resolve phase: the request is in flight before the component exists, and the component just reads the result.

@mmstack/router-corenpm

There is an old tension in Angular routing. Fetch in the component and you get a waterfall, the view has to exist before the request starts. Fetch in a resolver and the router blocks the whole navigation on it, so the page freezes until the slowest resolver finishes. Route-level data sidesteps both. The factory fires at the resolve phase, so the request starts early, but it does not block: the request runs while the transition outlet keeps the previous view on screen. The component reads a resource that is already loading by the time it mounts.

#The three pieces

You declare route data with three symbols that fit together. A typed routeDataKey names the slot and carries its type. Then provideRouteData(key) goes in the route's providers, setting up the per-route transition scope and a memoization slot. And createRouteData(key, factory) goes in the route's resolve map, which is what actually fires the factory. The component reads it back with injectRouteData(key), getting the very same instance the route already started.

import {
  routeDataKey,
  provideRouteData,
  createRouteData,
} from '@mmstack/router-core';
import { queryResource, type QueryResourceRef } from '@mmstack/resource';

const USER = routeDataKey<QueryResourceRef<User | undefined>>('user');

export const routes: Routes = [
  {
    path: 'users/:id',
    loadComponent: () => import('./user.page').then((m) => m.UserPage),
    providers: [provideRouteData(USER)],
    resolve: {
      user: createRouteData(USER, (ctx) =>
        queryResource(() => `/api/users/${ctx.param('id')()}`, {
          defaultValue: undefined,
          register: 'suspend', // the outlet holds until this settles
          cache: { staleTime: 30_000 }, // enables prefetch-on-hover
        }),
      ),
    },
  },
];

The component side is a single line. No ngOnInit, no ActivatedRoute.data subscription, no local state to hold the result: it is the same resource ref the route created, so reading user.value() reads a fetch that has been running since before this component existed.

import { injectRouteData } from '@mmstack/router-core';

@Component({ selector: 'user-page', template: `{{ user.value()?.name }}` })
export class UserPage {
  // the same instance the route already started, already in flight
  readonly user = injectRouteData(USER);
}

#How it behaves

  • Fires before the component. The factory runs at the resolve phase, so the request is in flight by the time the component mounts (hidden, under the transition outlet). Sibling and nested route data fire in the same activation pass, so a matched chain loads in parallel rather than in a waterfall.
  • Reactive params, defined once.ctx.params() and ctx.queryParams() are live signals that update on param or query changes without leaning on the route's runGuardsAndResolvers. You write the factory once and a query-param change refetches on its own. ctx.param('id') is the single-param sugar: a memoized signal that throws a dev-mode error if the name is not on the matched route, so a typo is a loud failure instead of a silent undefined.
  • Memoized. The factory runs once per route activation. A resolver that re-runs reuses the same instance. The data lives as long as the route and is destroyed with it.
  • Coordinates with the outlet.register: 'suspend' makes the transition outlet hold the previous view until the data settles; register: 'indicator' drives a busy indicator without blocking the swap. Both give the route its own transition scope, so it is isolated from other routes' loading.
  • No outlet required. Without a transition outlet the data still fires and is readable; you just do not get the held transition on top of it.
  • Refresh from the component. The slot is the resource ref itself, so injectRouteData(USER).reload() is your pull-to-refresh. No extra API to learn.

#Recipe: kill the resolver dilemma

A classic Angular resolver blocks the navigation: click the link, the URL does not change, the app appears to freeze, and only when the fetch resolves does the new page appear. To avoid the freeze people skip resolvers and fetch in the component, which brings back the waterfall and a page full of skeletons. Neither feels good.

Route-level data plus the transition outlet gives you the third option. The pieces below are the same as the setup above, the point is the behavior they combine into. On the route, register: 'suspend' is what tells the outlet to wait for this data.

// register: 'suspend' is the whole trick: the outlet waits for this
resolve: {
  user: createRouteData(USER, (ctx) =>
    queryResource(() => `/api/users/${ctx.param('id')()}`, {
      defaultValue: undefined,
      register: 'suspend',
    }),
  ),
}

In the shell, the only change is the outlet selector. It reads that registered resource and holds the previous page until the fetch lands.

@Component({
  selector: 'app-shell',
  imports: [TransitionRouterOutlet],
  template: `<mm-transition-outlet />`, // holds the old page until data lands
})
export class AppShell {}

Click the link and this is what the user sees: the URL updates immediately (navigation is not blocked), the previous page stays fully interactive on screen, and the fetch runs in the background. The moment the data lands, the new page swaps in complete, with data already present. No freeze, no skeleton, no flash of spinners. If you would rather show a skeleton on a particular route, set data: { immediateTransition: true } on it and it swaps in immediately with its own loading state instead.

#Handling errors

There are two levers, and they compose. Pass onError when you want route-level policy: redirect somewhere, toast and stay, log it. It fires once per transition into an error state (first load, reloads, param refetches) and never for a speculative prefetch error, so a failed hover-warm cannot trip your redirect.

createRouteData(USER, factory, {
  // redirect, toast-and-stay, log. Fires per transition into error
  // (first load, reloads, param re-fetches); never for prefetch errors.
  onError: (err, ctx) => ctx.injector.get(Router).navigateByUrl('/not-found'),
});

Leave onError off and the default takes over: the outlet swaps on settle-by-error and the component renders the slot's error(), the familiar in-view error boundary. Use the boundary for recoverable, in-place errors; use onError when the error means the route itself is not a place the user should be.

#Prefetch on hover

Opt in with withRouteData() and the same mmLink hover that warms a lazy chunk also warms the route's data. On hover or visibility the factory runs with params parsed straight from the link URL, filling your resource cache so the eventual click reads it warm and deduped. It is the classic prefetch-on-intent pattern wired to the links you already have.

import { provideRouter, withPreloading } from '@angular/router';
import { PreloadStrategy, withRouteData } from '@mmstack/router-core';

bootstrapApplication(App, {
  providers: [
    provideRouter(routes, withPreloading(PreloadStrategy)),
    withRouteData(), // hovering an mmLink now warms route data, not just code
  ],
});
  • Needs a cache to pay off. The warm writes to whatever shared cache your factory's resource uses, for example @mmstack/resource's provideQueryCache(). Without a shared cache, the hover fetch has nowhere to live and the navigation cannot reuse it.
  • Two hovers for lazy routes. The data factory is not visible until its chunk loads, so the first hover warms the code and the second warms the data. Eager routes warm data on the first hover.
  • On the prefetch path ctx.isPrefetch is true and params come from the hovered URL, so a factory can branch on it if a speculative fetch should behave differently.

One edge to know: on a reused route (/users/1 to /users/2) the resource refetches in place and there is no view swap for the outlet to hold. Wrap it with holdThroughNavigation for a flash-free, rollback-safe transition on the param change.

#Prefetch any resolver

createRouteData is not the only thing the hover pipeline can warm. You often have work that a hover should start speculatively but that is not a route-data resource: warming an i18n namespace, priming a dataset, seeding a cache. withPrefetch is the escape hatch. It tags any Angular ResolveFn so the same withRouteData() pipeline runs a speculative prefetch(ctx) on hover, while navigation runs the wrapped resolver unchanged.

import { withPrefetch } from '@mmstack/router-core';

// canonical use: warm a translate namespace on hover
resolve: {
  i18n: withPrefetch(quoteNs.resolveNamespaceTranslation, {
    description: 'quote-i18n',
    // runs speculatively on hover; params come from the link URL
    prefetch: (ctx) => quoteNs.warmNamespaceTranslation(ctx.params()['locale']),
  }),
}

The prefetch runs in the same throwaway root-parented injector as a route-data factory, with ctx.isPrefetch true and params extracted from the hovered URL, so anything it writes lands in your shared cache for the eventual click to reuse. Runs are deduped per link URL plus description. Reach for it when the speculative work is idempotent and cache-shaped, the kind of thing a hover can safely start and a click can safely repeat. Resolvers made by createRouteData are already tagged, so do not wrap those.