skip to content

In Angular, how do you pre-load an invoice with a ResolveFn before its edit page renders, and where does the component read it?

level: juniorimportance: must knowfreq 62%

answer

  1. a function on the route
  2. the resolve key in Routes
  3. inject() inside the function
  4. ActivatedRoute.data under the same key

basics

~10 s

Write a ResolveFn that injects the invoice service and returns the invoice, register it under the route's resolve key, and the router waits for it, then exposes the value on ActivatedRoute.data under that key.

solid answer

~40 s

A resolver is a plain function typed `ResolveFn<Invoice>` that receives the `ActivatedRouteSnapshot` and `RouterStateSnapshot`, calls `inject(InvoiceApi)` and returns the invoice as a value, a `Promise` or an `Observable`. You attach it with `resolve: { invoice: invoiceResolver }` on the `invoices/:id/edit` route. The router runs it during navigation, after the guards pass, and only activates the route once it has a value, so the edit component is created with the data already there. The component reads it from `inject(ActivatedRoute).data` under the key `invoice` (or `snapshot.data['invoice']`), or, with `withComponentInputBinding()`, as an `invoice` input. Passing an `@Injectable` class that implements `Resolve<T>` still works, but class and token resolvers are deprecated in favour of the function form.

code

ts · 24 lines
ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, ResolveFn, Routes } from '@angular/router';
import { map } from 'rxjs';
import { Invoice, InvoiceApi } from './invoice-api';

export const invoiceResolver: ResolveFn<Invoice> = (route) =>
  inject(InvoiceApi).getInvoice(route.paramMap.get('id')!);

@Component({
  selector: 'app-invoice-edit',
  template: `<h1>Edit invoice {{ invoice().number }}</h1>`,
})
export class InvoiceEdit {
  private readonly route = inject(ActivatedRoute);
  readonly invoice = toSignal(
    this.route.data.pipe(map((data) => data['invoice'] as Invoice)),
    { requireSync: true },
  );
}

export const routes: Routes = [
  { path: 'invoices/:id/edit', component: InvoiceEdit, resolve: { invoice: invoiceResolver } },
];

go deeper

for a junior

Recall the three pieces: a ResolveFn function, the resolve map on the route, and reading the value from ActivatedRoute data under the same key.

for a middle

Explain that the router runs resolvers after guards, waits for a value before creating the component, and why inject() works inside the function.

for a senior

Show you know the cost: a blocked click with no feedback, so pair resolvers with a global navigation indicator and keep them to must-have data.

for a principal

Frame resolvers as one policy for where page data is fetched, and set a team rule for when blocking navigation is acceptable versus rendering a skeleton.

## What a resolver is A **data resolver** is a function the Angular router calls while it is navigating, before it activates the target route. Its job is to fetch the data a screen cannot render without, so that the component is created with that data already in hand. In current Angular the resolver is a plain function with the type `ResolveFn<T>`, exported from `@angular/router`: - it receives two arguments: the **`ActivatedRouteSnapshot`** of the route being activated (params, query params, parent, static `data`) and the **`RouterStateSnapshot`** of the whole target state; - it may return the value itself, a `Promise` of it, or an `Observable` of it (the router's `MaybeAsync<T>` type); - it may also return a **`RedirectCommand`** to send the navigation somewhere else instead. The router calls the function inside an **injection context** built from the route's environment injector, so `inject()` works at the top of the function body. That is why no class, no constructor and no `providedIn` are needed. ## Wiring it to a route For an invoice edit page, the resolver reads `:id` from the snapshot and asks a service for the invoice: ```ts export const invoiceResolver: ResolveFn<Invoice> = (route) => inject(InvoiceApi).getInvoice(route.paramMap.get('id')!); export const routes: Routes = [ { path: 'invoices/:id/edit', component: InvoiceEdit, resolve: { invoice: invoiceResolver }, }, ]; ``` The **`resolve`** property is a map. The key you choose (`invoice`) is the key the data will have on the route. A route can list several resolvers side by side, for example `invoice` and `customer`. ## Reading the result in the component The router merges the resolved values into the route's **data** object. There are three common ways to read them: | Approach | How | When it fits | |---|---|---| | `ActivatedRoute.data` | `inject(ActivatedRoute).data` is an `Observable<Data>`; read `data['invoice']`, often through `toSignal` | the component may be reused for another invoice id | | `ActivatedRoute.snapshot.data` | `snapshot.data['invoice']`, read once | the component is always recreated for a new id | | Component input binding | enable `withComponentInputBinding()` in `provideRouter`, declare `invoice = input.required<Invoice>()` | you want typed inputs and no `ActivatedRoute` injection | Resolved values take precedence over a static `data` entry with the same key on that route. Because the default `paramsInheritanceStrategy` is `'always'` since v22, a child route's data also includes what its parent's resolvers produced. ## What the user experiences The resolver **blocks the navigation**. After the user clicks "Edit", the old page stays on screen until the invoice has arrived; only then is `InvoiceEdit` created. The payoff is that the component never renders an empty form or a loading state for its main data. The cost is a click that seems to do nothing on a slow network, so apps usually show a global progress bar driven by the router (for example from `Router.currentNavigation()`, a signal that is non-null while a navigation is in flight). ## Where the resolver gets its services Because the router runs the function with the **closest environment injector** of the route, a resolver sees root-level services and also any `providers` declared on the route itself or on a lazily loaded parent route. That makes it natural to scope an `InvoiceApi` or an invoice cache to the `invoices` feature routes and still inject it in the resolver. The flip side is that the resolver cannot inject anything provided only by a component, since no component exists yet when it runs. If the resolver needs data from a parent route, it reads `route.parent?.data`, which the parent's resolvers have already filled in. ## Current style versus older code 1. **Functional resolvers** (`ResolveFn`) are the recommendation. They are tree-shakable and need no provider. 2. **Class resolvers** implementing `Resolve<T>` with `@Injectable()` still appear in older codebases. The router still accepts them, but the router's `ResolveData` type marks class and token resolvers as deprecated. A class can be adapted with `invoice: () => inject(InvoiceResolver).resolve(...)`. 3. Resolvers were historically registered in NgModule-era `RouterModule.forRoot(routes)` arrays; with standalone apps the same `Routes` array goes to `provideRouter(routes)`. ## What a resolver should not do - It should fetch only what the page cannot render without, not everything the page might show, because every millisecond is a click with no visible result. - It should not keep a long-lived subscription: the router takes the **first** value the Observable emits and ignores the rest. - It should handle failure, or the navigation ends in a `NavigationError` and the user stays where they were.

  • Why can the resolver call inject() even though it is not a class with a constructor?
    The router invokes a functional resolver with `runInInjectionContext` on the route's environment injector. Inside that call `inject()` is legal, and it sees root providers plus any `providers` declared on the route or its lazy-loaded parents. Calling `inject()` later, for example inside a `setTimeout` callback, would fail because the injection context only lasts for the synchronous call.
  • How would you type the resolved value when reading it through component input binding?
    With `provideRouter(routes, withComponentInputBinding())`, the router sets inputs whose names match route data keys. Declaring `invoice = input.required<Invoice>()` in the edit component gives a typed signal with no `ActivatedRoute` injection and no string-key cast. The resolver key and the input name must match exactly.

A resolver is like a waiter who will not seat you until your reserved dish is on the table: you wait longer at the door, but you never sit at an empty place setting.

saying these in an interview costs you the question

  • Resolvers must be @Injectable classes implementing Resolve
  • The component fetches first and the resolver fills in later
  • Resolved data arrives as a separate ActivatedRoute property, not route data
  • A resolver makes the navigation instant because data is prefetched
  • inject() cannot be used inside a functional resolver