Route Resolvers in Angular
Route resolvers pre-fetch data before a route activates, so the component renders with data already available — no loading spinners inside the component and no empty-state flicker.
A resolver is a service (or a plain function in modern Angular) that implements the ResolveFn<T> type. The router calls it, waits for the Observable/Promise to complete, and only then navigates to the target route.
Why Use Resolvers?
Without a resolver the typical flow is:
- Navigate to the route
- Component renders with an empty/undefined model
- Component calls a service in
ngOnInit - Template shows a spinner until data arrives
With a resolver:
- Router calls the resolver
- Resolver fetches data from the API
- Navigation completes with data in
ActivatedRoute.data - Component renders immediately with real data
Without Resolver | With Resolver |
|---|---|
Component manages loading state | Data arrives before component renders |
Template needs null-guards | Data is guaranteed on init |
Loading spinner inside component | Navigation-level loading indicator |
Complex ngOnInit logic | Simple route.data access |
Creating a Functional Resolver (Modern Angular 14+)
Since Angular 14 you can use a plain ResolveFn instead of a class-based resolver. This is the recommended approach.
// src/app/resolvers/product.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { ProductService } from '../services/product.service';
import { Product } from '../models/product.model';
export const productResolver: ResolveFn<Product> = (route, state) => {
const productService = inject(ProductService);
const id = route.paramMap.get('id')!;
return productService.getProduct(+id);
};inject() function to access services — no constructor injection needed.Registering the Resolver on a Route
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { productResolver } from './resolvers/product.resolver';
import { ProductDetailComponent } from './product-detail/product-detail.component';
export const routes: Routes = [
{
path: 'products/:id',
component: ProductDetailComponent,
resolve: {
product: productResolver, // key becomes available as route.data['product']
},
},
];Reading Resolved Data in the Component
// product-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Product } from '../models/product.model';
import { CurrencyPipe } from '@angular/common';
@Component({
selector: 'app-product-detail',
standalone: true,
imports: [CurrencyPipe],
template: `
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<p>Price: {{ product.price | currency }}</p>
`,
})
export class ProductDetailComponent implements OnInit {
product!: Product;
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
// Data is already resolved — no async loading needed
this.product = this.route.snapshot.data['product'];
}
}this.route.data (Observable) instead of snapshot.data if the same component instance can be reused across different route params without being destroyed and re-created.Class-Based Resolver (Angular 12–13 Style)
Before Angular 14, resolvers were class-based services implementing the Resolve interface. You may still encounter these in existing codebases.
// src/app/resolvers/product-class.resolver.ts
import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { ProductService } from '../services/product.service';
import { Product } from '../models/product.model';
@Injectable({ providedIn: 'root' })
export class ProductClassResolver implements Resolve<Product> {
constructor(private productService: ProductService) {}
resolve(route: ActivatedRouteSnapshot): Observable<Product> {
const id = route.paramMap.get('id')!;
return this.productService.getProduct(+id);
}
}
// In routes array:
// resolve: { product: ProductClassResolver }Error Handling in Resolvers
If the resolver throws or the Observable errors, navigation is cancelled by default. Use catchError to redirect or provide a fallback.
// src/app/resolvers/product-safe.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn, Router } from '@angular/router';
import { catchError, EMPTY } from 'rxjs';
import { ProductService } from '../services/product.service';
import { Product } from '../models/product.model';
export const productSafeResolver: ResolveFn<Product> = (route) => {
const productService = inject(ProductService);
const router = inject(Router);
const id = route.paramMap.get('id')!;
return productService.getProduct(+id).pipe(
catchError((err) => {
console.error('Product not found', err);
router.navigate(['/not-found']);
return EMPTY; // cancel navigation silently
})
);
};EMPTY cancels the navigation entirely. Return a fallback value (e.g., a default Product object) if you want navigation to proceed despite the error.Multiple Resolvers on a Single Route
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { userResolver } from './resolvers/user.resolver';
import { productsResolver } from './resolvers/products.resolver';
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
resolve: {
user: userResolver,
products: productsResolver, // both fetched in parallel!
},
},
];
// dashboard.component.ts
ngOnInit(): void {
const data = this.route.snapshot.data;
this.user = data['user'];
this.products = data['products'];
}Global Loading Indicator with Router Events
Since resolution happens at the router level, listen to RouterEvents to show a global progress bar while resolvers are running.
// app.component.ts
import { Component } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { RouterOutlet } from '@angular/router';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, NgIf],
template: `
<div *ngIf="loading" class="global-spinner">Loading...</div>
<router-outlet />
`,
})
export class AppComponent {
loading = false;
constructor(private router: Router) {
this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
this.loading = true;
} else if (
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError
) {
this.loading = false;
}
});
}
}Resolver with Signal-Based Components (Angular 17+)
// product-signal.component.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-product-signal',
standalone: true,
template: `
@if (product(); as p) {
<h1>{{ p.name }}</h1>
<p>{{ p.description }}</p>
}
`,
})
export class ProductSignalComponent {
private route = inject(ActivatedRoute);
product = toSignal(
this.route.data.pipe(map((data) => data['product']))
);
}Best Practices
Prefer functional ResolveFn over class-based resolvers for new Angular 14+ code
Always handle errors with catchError — never let a resolver silently kill navigation
Keep resolvers thin: delegate real logic to injectable services
Use EMPTY to cancel navigation on 404; return a fallback value to allow navigation
Multiple resolvers on one route run in parallel — split independent data fetches
Use NavigationStart/NavigationEnd events for a global loading indicator
Avoid heavy computation in resolvers — they are for data fetching only
Summary
Route resolvers guarantee data availability before a component renders. Modern Angular favors the functional ResolveFn API with inject(), making resolvers lightweight and easy to unit-test. Pair them with a global loading indicator driven by router events for a polished navigation experience.