Angular 27 🅰️ Resolvers and Route Data
A component that fetches its own data shows an empty state, then a loading spinner, then the content. That flicker — three states for what should be one — is the problem resolvers solve. A resolver runs before the route activates, fetches the data, and hands it to the component ready to use. By the time the component renders, the data is already there. Alongside resolvers, Angular provides two more ways to attach data to a route: static data for constants, and resolved data from a resolver. Together they let you model the difference between “this route always has this data” and “this route needs to fetch this data first.”
Key point: data on a route is a static object — merged from parent to child, readable in the component. A resolver is a function that returns a value, promise, or observable; the router waits for it before activating the route. The resolved value lands in ActivatedRoute.data, alongside the static data. The component reads both from the same place. The whole point: the component renders with its data already present, no flicker, no loading state for the primary content.
What a resolver is
A resolver is a function that provides data to a route before the route activates.
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';
export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(UserService);
const id = Number(route.paramMap.get('id'));
return userService.getUser(id);
};
The resolver returns a User. The router waits for the promise to resolve before activating the route.
What a resolver does:
- Runs before the route activates
- Fetches or computes data
- Returns the data (sync, promise, or observable)
- Attaches the resolved value to the route
What a resolver doesn’t do:
- Doesn’t render anything
- Doesn’t replace guards — guards decide, resolvers fetch
- Doesn’t cache — runs on every activation
- Doesn’t handle errors by default — you do that inside
The ResolveFn<T> signature:
(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => T | Promise<T> | Observable<T>
Returns the data. The router unwraps the promise or observable before activating.
Why resolvers exist: Without them, every component has to handle three states — loading, error, ready. Resolvers centralize the fetch and hand the component ready data. The component becomes simpler and the UI doesn’t flicker.
Why “resolver”: It resolves the data the route needs. The name matches the operation — the route depends on this data, and the resolver provides it.
Why resolvers aren’t always the right answer: Sometimes a loading spinner is the correct UX — when the fetch is slow and the user should see progress. Resolvers delay navigation until data is ready; during that time the previous page stays visible. For slow fetches, that can feel like the app is stuck. Use resolvers when the data is required for the route to make sense, and skip them when a partial render with a spinner is better.
Creating a resolver
A resolver is a function typed as ResolveFn<T>.
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
export interface User {
id: number;
name: string;
email: string;
}
export const userResolver: ResolveFn<User> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService).getUser(id);
};
What the resolver receives:
route: ActivatedRouteSnapshot— the route being activated, with access toparamMap,queryParamMap,data, andparentstate: RouterStateSnapshot— the full router state
What it returns:
- Sync value — resolved immediately
- Promise — router waits
- Observable — router waits for the first emission (and completes)
Attaching to a route:
{
path: 'users/:id',
component: UserDetailComponent,
resolve: { user: userResolver }
}
The key (user) is the property name in ActivatedRoute.data. The value is the resolver function.
Multiple resolvers:
{
path: 'users/:id',
component: UserDetailComponent,
resolve: {
user: userResolver,
posts: userPostsResolver
}
}
Both run; the router waits for both. The component reads route.data['user'] and route.data['posts'].
Reading the resolved data:
@Component({ /* ... */ })
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user = this.route.snapshot.data['user'] as User;
}
data holds both static route data and resolved data, keyed by the resolver’s key.
Async resolvers: A resolver that returns a promise or observable is fine — the router handles it.
export const userResolver: ResolveFn<User> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService).getUser(id); // returns Promise<User>
};
Why the ResolveFn<T> type parameter: It tells TypeScript the type of the resolved value. The component can read it without casting if you use the type properly.
Why the data key matters: The router stores the value under the key you provide. That key is how the component finds it.
Why resolvers run before guards or after: The order is guards first, then resolvers. A guard can deny access without running the resolver. That’s efficiency — if the user isn’t allowed, no fetch happens. Once guards pass, resolvers run and the component activates with data.
Resolving as a function vs a class
Angular 15 introduced functional resolvers. Classes still work but are legacy.
Functional resolver — the modern form:
export const userResolver: ResolveFn<User> = (route) => {
return inject(UserService).getUser(Number(route.paramMap.get('id')));
};
Class-based resolver — the legacy form:
@Injectable({ providedIn: 'root' })
export class UserResolver implements Resolve<User> {
constructor(private userService: UserService) {}
resolve(route: ActivatedRouteSnapshot): Observable<User> {
return this.userService.getUser(Number(route.paramMap.get('id')));
}
}
The differences:
| Aspect | Functional | Class |
|---|---|---|
| Form | Function | Class with resolve method |
| Dependencies | inject() | Constructor injection |
| Boilerplate | None | @Injectable, implements |
| Testability | Direct | Needs TestBed or manual |
| Modern default | ✅ | Legacy |
Using a class resolver:
{
path: 'users/:id',
component: UserDetailComponent,
resolve: { user: UserResolver }
}
The class is referenced directly — Angular instantiates it.
Why functional is preferred:
- Less boilerplate
- Uses
inject()— consistent with modern Angular - Easier to test — call the function directly
- Composes with other utilities
Why class resolvers still exist: Legacy code and libraries. New code should use functions.
Why “Resolve” vs “ResolveFn”: Resolve<T> is the interface a class implements. ResolveFn<T> is the function type. The Fn suffix marks the functional form.
Why the shift to functions: Class resolvers need
@Injectable, a constructor, and animplementsclause. Functional resolvers are one line. In a large app with dozens of resolvers, that’s a real reduction. And functional resolvers compose better — you can wrap, memoize, or parameterize them like any function.
Route data — static data
Every route can carry a data object — static, merged from parent to child.
{
path: 'settings',
component: SettingsComponent,
data: { title: 'Settings', requiresAuth: true }
}
Reading static data:
export class SettingsComponent {
private route = inject(ActivatedRoute);
title = this.route.snapshot.data['title'];
}
Data inheritance: Children inherit the parent’s data. Their own data merges on top.
{
path: 'admin',
data: { section: 'admin' },
children: [
{
path: 'users',
component: UsersComponent,
data: { title: 'Users' }
}
]
}
Visiting /admin/users gives the child’s route.data as { section: 'admin', title: 'Users' } — the parent’s merged in.
Read parent data explicitly:
const section = this.route.parent?.snapshot.data['section'];
Common uses for static data:
| Key | Use |
|---|---|
title | Page title |
breadcrumb | Breadcrumb label |
requiresAuth | Metadata for guards |
preload | Custom preloading flag |
animation | Animation state name |
Why static data is useful: It’s metadata attached to a route without a fetch. Titles, breadcrumbs, and flags can all live on the route and be read by any component or service.
Why merge with resolver data: route.data is the single place for route-attached data, whether static or resolved. Components read one place; the framework fills it from both sources.
Why the field name is data: It’s the data for the route — anything the route carries. The name is generic because the uses are.
Why static data and resolvers coexist: Some data is known at config time (titles, flags) and doesn’t need a fetch. Some data comes from a service and must be resolved. Both land in
route.data— same access pattern, different sources. The component doesn’t care which.
Reading resolved and static data
The component reads everything from ActivatedRoute.data.
Snapshot — one-time read:
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user = this.route.snapshot.data['user'] as User;
title = this.route.snapshot.data['title'] as string;
}
Simple, but stale if the route is reused.
Observable — reactive:
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user$ = this.route.data.pipe(map(data => data['user'] as User));
}
The observable emits when data changes — for a reused route with new params.
With signals:
import { toSignal } from '@angular/core/rxjs-interop';
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user = toSignal(
this.route.data.pipe(map(d => d['user'] as User))
);
}
user is a signal. Updates on navigation changes.
The snapshot vs observable distinction: If the route is reused (same component, different params), the snapshot is stale but the observable emits. Use the observable when in doubt.
Typed access with a helper:
function resolvedData<T>(route: ActivatedRoute): T {
return route.snapshot.data as T;
}
interface UserRouteData {
user: User;
title: string;
}
export class UserDetailComponent {
private route = inject(ActivatedRoute);
data = resolvedData<UserRouteData>(this.route);
}
The helper casts the data object to a typed shape. You get autocomplete and type checking.
Why data['x'] as T is needed: route.data is typed Data — an any-valued map. TypeScript can’t know what keys exist. The cast (or a typed helper) restores the shape. It’s a promise about the resolver’s output.
Why a typed helper: Repeated casts get noisy. A helper centralizes them. Define an interface for the route’s data and use it everywhere.
Why signals are the modern choice: They compose with computed and effect, integrate with the template, and behave like any other signal. The toSignal bridge keeps the router’s reactive model and Angular’s signal model aligned.
Why
route.dataisn’t strongly typed: Data keys are string properties populated at runtime by resolvers and configs. The compiler can’t know them from the route config alone. The type isData— effectivelyRecord<string, any>. Cast or use a helper to restore type safety.
Resolvers vs guards
Both run before the component activates, but they do different things.
| Aspect | Guard | Resolver |
|---|---|---|
| Purpose | Decide | Fetch |
| Returns | Boolean, UrlTree | Data |
| Cancels navigation | ✅ | On error |
| Attaches to route | No | ✅ (route.data) |
| Runs first | ✅ | After guards |
The order:
- Guards run. Any denial cancels navigation — resolvers don’t run.
- Resolvers run. They fetch data.
- Component activates with data ready.
Why guards first: It’s efficient. If the user isn’t allowed, don’t waste time fetching. Guards decide access; resolvers prepare data. The separation keeps each focused.
Errors in resolvers: If a resolver throws or the promise rejects, navigation fails. The router emits NavigationError. Handle errors inside the resolver if you want navigation to continue with a fallback.
Handling errors in a resolver:
export const userResolver: ResolveFn<User | null> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService).getUser(id).catch(() => null);
};
The resolver returns null on error — navigation proceeds with a fallback value.
Why catch inside the resolver: The router has no default error handler. A rejected resolver cancels navigation. If the route can work with a fallback, catch and return it. Otherwise, let the error propagate and handle it at the app level.
Why the resolver’s return type often includes null: If a fetch might not find data, the return type is T | null. The component handles the absence.
Why the resolver runs on every activation: Unlike services, resolvers aren’t singletons. They run every time the route activates. If the data should be cached, cache it inside the service the resolver calls.
Why both guards and resolvers exist: They solve different problems. Guards answer “can this user go here?” Resolvers answer “what does this route need?” Mixing them would blur the responsibility. Splitting them keeps each simple.
A full example
A user detail route with a resolver, static data, and error handling.
// ============================================
// TYPES
// ============================================
export interface User {
id: number;
name: string;
email: string;
}
export interface Post {
id: number;
title: string;
body: string;
}
// ============================================
// SERVICE
// ============================================
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUser(id: number): Promise<User> {
return this.http.get<User>(`/api/users/${id}`).toPromise() as Promise<User>;
}
getUserPosts(id: number): Promise<Post[]> {
return this.http
.get<Post[]>(`/api/users/${id}/posts`)
.toPromise() as Promise<Post[]>;
}
}
// ============================================
// RESOLVERS
// ============================================
import { ResolveFn, ActivatedRouteSnapshot } from '@angular/router';
export const userResolver: ResolveFn<User | null> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService)
.getUser(id)
.catch(() => null); // fallback on error
};
export const userPostsResolver: ResolveFn<Post[]> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService)
.getUserPosts(id)
.catch(() => []);
};
// ============================================
// COMPONENT
// ============================================
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-user-detail',
standalone: true,
imports: [CommonModule],
template: `
@if (user()) {
<h1>{{ user()!.name }}</h1>
<p>{{ user()!.email }}</p>
<h2>Posts ({{ posts().length }})</h2>
<ul>
@for (post of posts(); track post.id) {
<li>{{ post.title }}</li>
}
</ul>
} @else {
<p>User not found.</p>
}
`
})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user = signal<User | null>(null);
posts = signal<Post[]>([]);
constructor() {
this.route.data
.pipe(
takeUntilDestroyed(),
map(data => ({
user: data['user'] as User | null,
posts: data['posts'] as Post[]
}))
)
.subscribe(({ user, posts }) => {
this.user.set(user);
this.posts.set(posts);
});
}
}
// ============================================
// ROUTES
// ============================================
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'users/:id',
component: UserDetailComponent,
data: { title: 'User Detail' }, // static
resolve: {
user: userResolver, // resolved
posts: userPostsResolver // resolved
}
}
];
What this shows:
userResolver— fetches the user, returnsnullon erroruserPostsResolver— fetches posts, returns[]on error- Static
data—titlefrom the route config - Component — reads
route.datareactively, updates signals - Template — renders immediately with data present, no loading state
By the time UserDetailComponent renders, both user and posts are ready.
Why this shape: It’s a real detail page. Two resolvers, a fallback for missing data, and the component reading from
route.data. Because the resolvers run first, the component never shows a loading state — data is there when it renders.
Complete Example Session
# ============================================
# PART 1: BASIC RESOLVER
# ============================================
cat > user.resolver.ts << 'EOF'
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';
export const userResolver: ResolveFn<{ id: number; name: string }> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService).getUser(id);
};
EOF
npx tsc --noEmit user.resolver.ts
# (no errors)
# ============================================
# PART 2: STATIC DATA
# ============================================
cat > static-data.ts << 'EOF'
import { Routes } from '@angular/router';
const routes: Routes = [
{
path: 'settings',
data: { title: 'Settings', requiresAuth: true },
children: [
{
path: 'profile',
data: { breadcrumb: 'Profile' },
children: []
}
]
}
];
export { routes };
EOF
npx tsc --noEmit static-data.ts
# (no errors)
# ============================================
# PART 3: READING DATA
# ============================================
cat > read-data.ts << 'EOF'
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
interface RouteData {
title: string;
user: { id: number; name: string } | null;
}
@Component({
selector: 'app-user',
standalone: true,
template: `<h1>{{ title }}</h1>`
})
export class UserComponent {
private route = inject(ActivatedRoute);
private data = this.route.snapshot.data as RouteData;
title = this.data.title;
user = this.data.user;
}
EOF
npx tsc --noEmit read-data.ts
# (no errors)
# ============================================
# PART 4: REACTIVE DATA
# ============================================
cat > reactive-data.ts << 'EOF'
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
interface RouteData {
user: { id: number; name: string } | null;
}
@Component({
selector: 'app-reactive',
standalone: true,
template: `<p>{{ user()?.name }}</p>`
})
export class ReactiveComponent {
private route = inject(ActivatedRoute);
user = signal<RouteData['user']>(null);
constructor() {
this.route.data
.pipe(
takeUntilDestroyed(),
map(data => data['user'] as RouteData['user'])
)
.subscribe(user => this.user.set(user));
}
}
EOF
npx tsc --noEmit reactive-data.ts
# (no errors)
# ============================================
# PART 5: RESOLVER WITH ERROR HANDLING
# ============================================
cat > error-resolver.ts << 'EOF'
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';
export const userResolver: ResolveFn<{ id: number; name: string } | null> = (route) => {
const id = Number(route.paramMap.get('id'));
return inject(UserService)
.getUser(id)
.catch(() => null);
};
EOF
npx tsc --noEmit error-resolver.ts
# (no errors)
# ============================================
# PART 6: COMBINING RESOLVERS
# ============================================
cat > combined.ts << 'EOF'
import { Routes } from '@angular/router';
import { userResolver } from './user.resolver';
import { userPostsResolver } from './user-posts.resolver';
const routes: Routes = [
{
path: 'users/:id',
data: { title: 'User' },
resolve: {
user: userResolver,
posts: userPostsResolver
},
loadComponent: () => import('./user-detail.component').then(m => m.UserDetailComponent)
}
];
export { routes };
EOF
npx tsc --noEmit combined.ts
# (no errors)
# ============================================
# PART 7: TESTING A RESOLVER
# ============================================
cat > resolver.spec.ts << 'EOF'
import { TestBed } from '@angular/core/testing';
import { userResolver } from './user.resolver';
import { UserService } from './user.service';
describe('userResolver', () => {
let service: jasmine.SpyObj<UserService>;
beforeEach(() => {
service = jasmine.createSpyObj('UserService', ['getUser']);
TestBed.configureTestingModule({
providers: [{ provide: UserService, useValue: service }]
});
});
it('resolves the user', async () => {
service.getUser.and.returnValue(Promise.resolve({ id: 1, name: 'Alice' }));
const route = { paramMap: { get: () => '1' } } as any;
const result = await TestBed.runInInjectionContext(() =>
userResolver(route, {} as any)
);
expect(result).toEqual({ id: 1, name: 'Alice' });
});
});
EOF
echo "Run tests: ng test"
# ============================================
# PART 8: TYPED HELPERS
# ============================================
cat > helpers.ts << 'EOF'
import { ActivatedRoute } from '@angular/router';
import { inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
function resolvedData<T>(route: ActivatedRoute): T {
return route.snapshot.data as T;
}
function resolvedData$(route: ActivatedRoute) {
return route.data;
}
interface UserRouteData {
user: { id: number; name: string } | null;
title: string;
}
export function useUserRouteData() {
const route = inject(ActivatedRoute);
const data = signal<UserRouteData | null>(null);
route.data
.pipe(takeUntilDestroyed(), map(d => d as UserRouteData))
.subscribe(d => data.set(d));
return data;
}
EOF
npx tsc --noEmit helpers.ts
# (no errors)
# ============================================
# SUMMARY
# ============================================
cat << 'EOF'
Resolvers and route data:
static data:
{ path: 'x', data: { title: 'X' } }
→ route.data.title
resolver:
resolve: { user: userResolver }
→ route.data.user
Both read from route.data
Order of operations:
1. Guards run
2. Resolvers run
3. Component activates with data
Reading:
snapshot.data → one-time
route.data.pipe(...) → reactive
toSignal(route.data) → signal
Errors:
Resolver throws → navigation cancels
Catch inside resolver for fallback
EOF
Quick Reference
Resolver Signature
| Form | Meaning |
|---|---|
ResolveFn<T> | Functional resolver |
Resolve<T> | Class-based interface |
(route, state) => T | Sync return |
(route, state) => Promise<T> | Async return |
(route, state) => Observable<T> | Reactive return |
Attaching Resolvers
{
path: 'x',
component: XComponent,
resolve: {
user: userResolver,
posts: postsResolver
}
}
Reading Resolved Data
| Method | Result |
|---|---|
route.snapshot.data['user'] | One-time |
route.data.pipe(map(...)) | Reactive |
toSignal(route.data) | Signal |
Static Data
{
path: 'x',
data: {
title: 'X',
breadcrumb: 'X',
requiresAuth: true
}
}
Data Inheritance
| Level | Data |
|---|---|
| Parent | Its data |
| Child | Parent’s merged with its own |
| Grandchild | All ancestors merged |
Reading Parent Data
route.parent?.snapshot.data['key']
Functional vs Class
| Aspect | Functional | Class |
|---|---|---|
| Type | ResolveFn<T> | Resolve<T> |
| Deps | inject() | Constructor |
| Use | Modern | Legacy |
| File | Often one function | Full class |
Resolver Order
| Step | Runs |
|---|---|
| 1 | Guards |
| 2 | Resolvers |
| 3 | Activate component |
Guards deny → resolvers skip.
Error Handling
| Approach | Behavior |
|---|---|
| Throw | Navigation fails |
.catch(() => null) | Navigation proceeds |
.catch(() => []) | Empty array fallback |
Common Resolver Patterns
| Pattern | Returns |
|---|---|
| Fetch one | Promise<T | null> |
| Fetch many | Promise<T[]> |
| First value | Observable<T> |
| Complex combination | forkJoin |
Combining with forkJoin
export const dataResolver: ResolveFn<{ user: User; posts: Post[] }> = (route) => {
const id = Number(route.paramMap.get('id'));
const svc = inject(UserService);
return forkJoin({
user: from(svc.getUser(id)),
posts: from(svc.getUserPosts(id))
});
};
Testing a Resolver
TestBed.runInInjectionContext(() =>
userResolver(route as any, {} as any)
);
Data Types
interface RouteData {
title: string;
user: User | null;
}
const data = route.snapshot.data as RouteData;
Error Cases
| Error | Cause |
|---|---|
NavigationError | Resolver threw |
Cannot read property | Data key missing |
Any on data | Data is untyped |
| Infinite loading | Resolver never resolves |
Data vs Resolver
| Aspect | Static Data | Resolver |
|---|---|---|
| Source | Config | Function |
| Fetches | No | Yes |
| Async | No | Yes |
| Available at config time | ✅ | ❌ |
| Merged from parent | ✅ | ❌ |
| Cancels on error | No | Yes |
When to Use a Resolver
| Situation | Resolver? |
|---|---|
| Data required to render | ✅ |
| No flicker desired | ✅ |
| Fetch is fast | ✅ |
| Fetch is slow | ❌ (loading UI is better) |
| Data optional | ❌ (load in component) |
| Multiple views share data | ✅ |
Best Practices
✅ Do This:
// Use functional resolvers
export const userResolver: ResolveFn<User> = (route) =>
inject(UserService).getUser(Number(route.paramMap.get('id'))); // ✅
// Handle errors inside the resolver
return inject(UserService).getUser(id).catch(() => null); // ✅
// Use static data for titles and flags
data: { title: 'Users', breadcrumb: 'Users' } // ✅
// Read data from route.data
user = this.route.snapshot.data['user'] as User; // ✅
// Subscribe for reactive access
this.route.data.pipe(takeUntilDestroyed()).subscribe(...); // ✅
// Use signals for modern components
user = toSignal(this.route.data.pipe(map(d => d['user']))); // ✅
// Define a typed interface for route data
interface UserRouteData { user: User; posts: Post[]; } // ✅
// Use forkJoin for multiple async dependencies
return forkJoin({ user: ..., posts: ... }); // ✅
// Cache expensive fetches in the service
// The resolver runs every activation // ✅
// Test with TestBed.runInInjectionContext
TestBed.runInInjectionContext(() => userResolver(...)); // ✅
❌ Don’t Do This:
// Don't use a resolver for slow data
// The user waits on a blank screen during navigation // ⚠️
// Don't throw in the resolver without handling
return this.service.get(id); // ⚠️ cancels navigation on error // ⚠️
// Don't forget the resolve key
resolve: { user: userResolver } // needed for data['user'] // ✅
// Don't skip the type cast
const user = this.route.snapshot.data['user']; // ⚠️ any // ⚠️
// Don't fetch data the component could load
// Resolvers are for blocking data // ⚠️
// Don't use class resolvers in new code
@Injectable() class UserResolver implements Resolve<User> {} // ⚠️
// Don't forget the parent's data merge
route.data // includes parent's data // ✅
// Don't reuse resolvers across routes without care
// They run every activation, on the specific route // ⚠️
// Don't rely on the resolver for state
// State belongs in services or component signals // ⚠️
// Don't overuse resolvers
// Sometimes a loading spinner is the better UX // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Slow resolver | Blank screen during navigation | Use loading UI instead |
| Missing resolve key | data['user'] undefined | Match the key in the route |
| Unhandled error | Navigation fails silently | Catch inside the resolver |
| Snapshot on reuse | Stale data | Subscribe to route.data |
Missing as cast | any type | Define a route data interface |
| Forgot parent data | Child doesn’t see ancestor | Read route.parent.data |
| Resolver returns undefined | No data | Return a value or throw |
| No caching | Refetches every time | Cache in the service |
| Confusing data and resolver | Wrong tool | Static = config; resolver = fetch |
| Forgetting to test | Silent failures | Test with runInInjectionContext |
Real-World Examples
1. Basic user resolver
export const userResolver: ResolveFn<User> = (route) =>
inject(UserService).getUser(Number(route.paramMap.get('id')));
2. Resolver with fallback
export const userResolver: ResolveFn<User | null> = (route) =>
inject(UserService).getUser(Number(route.paramMap.get('id'))).catch(() => null);
3. Posts resolver
export const postsResolver: ResolveFn<Post[]> = (route) =>
inject(PostService).getPostsByUser(Number(route.paramMap.get('id')));
4. Multiple resolvers
resolve: {
user: userResolver,
posts: postsResolver
}
5. Static data
data: { title: 'Settings', breadcrumb: 'Settings' }
6. Parent data merge
{
path: 'admin',
data: { section: 'admin' },
children: [
{ path: 'users', data: { title: 'Users' } }
]
}
7. Read snapshot data
user = this.route.snapshot.data['user'] as User;
8. Read reactive data
this.route.data.subscribe(d => this.user.set(d['user'] as User));
9. Read as signal
user = toSignal(this.route.data.pipe(map(d => d['user'] as User)));
10. Resolve with forkJoin
return forkJoin({
user: from(svc.getUser(id)),
posts: from(svc.getPosts(id))
});
11. Error fallback
.catch(() => ({ id: 0, name: 'Unknown' }))
12. Optional data
export const optionalResolver: ResolveFn<Post[] | null> = (route) => {
return inject(PostService).getPosts().catch(() => null);
};
13. Composite data
interface UserPageData {
user: User;
posts: Post[];
title: string;
}
14. Typed helper
function useRouteData<T>() {
const route = inject(ActivatedRoute);
return toSignal(route.data.pipe(map(d => d as T)));
}
15. Route with data and resolver
{
path: 'users/:id',
resolve: { user: userResolver },
data: { title: 'User Detail' }
}
16. Guard + resolver
{
path: 'admin',
canActivate: [authGuard],
resolve: { data: adminDataResolver }
}
17. Resolver with query params
export const searchResolver: ResolveFn<SearchResult[]> = (route) => {
const q = route.queryParamMap.get('q') ?? '';
return inject(SearchService).search(q);
};
18. Resolver with parent param
export const postResolver: ResolveFn<Post> = (route) => {
const userId = Number(route.parent?.paramMap.get('userId'));
const postId = Number(route.paramMap.get('postId'));
return inject(PostService).getPost(userId, postId);
};
19. Conditional resolver
export const dataResolver: ResolveFn<User | null> = (route) => {
const id = Number(route.paramMap.get('id'));
return isNaN(id) ? Promise.resolve(null) : inject(UserService).getUser(id);
};
20. Testing a resolver
const result = await TestBed.runInInjectionContext(() =>
userResolver(route as any, {} as any)
);
Visual: Resolver Flow
┌──────────────────────────────────────────────┐
│ User navigates to /users/42 │
│ │ │
│ ▼ │
│ Guards run │
│ │ │
│ ├── Denied → cancel │
│ │ │
│ └── Allowed │
│ │ │
│ ▼ │
│ Resolvers run │
│ │ │
│ ├── Fetch user │
│ ├── Fetch posts │
│ └── Both complete │
│ │ │
│ ▼ │
│ Component activates │
│ with data ready │
│ (no flicker) │
│ │
└──────────────────────────────────────────────┘
Visual: Data Merge
┌──────────────────────────────────────────────┐
│ Parent route: │
│ { │
│ path: 'admin', │
│ data: { section: 'admin' }, │
│ resolve: { user: userResolver } │
│ } │
│ │
└──────────────────────────────────────────────┘
│
│ merged with
▼
┌──────────────────────────────────────────────┐
│ Child route: │
│ { │
│ path: 'users', │
│ data: { title: 'Users' }, │
│ resolve: { posts: postsResolver } │
│ } │
│ │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ route.data for child: │
│ { │
│ section: 'admin', │
│ title: 'Users', │
│ user: ..., │
│ posts: ... │
│ } │
│ │
│ All merged into one object │
│ │
└──────────────────────────────────────────────┘
Visual: Snapshot vs Observable
┌──────────────────────────────────────────────┐
│ URL: /users/1 │
│ snapshot.data['user'] → User 1 │
│ observable emits User 1 │
│ │
└──────────────────────────────────────────────┘
│
│ navigate /users/2
▼
┌──────────────────────────────────────────────┐
│ Component reused (route same shape) │
│ │
│ snapshot.data['user'] → still User 1 ⚠️ │
│ observable emits User 2 ✅ │
│ │
└──────────────────────────────────────────────┘
Visual: Order of Operations
┌──────────────────────────────────────────────┐
│ Navigation starts │
│ │ │
│ ▼ │
│ CanMatch │
│ │ │
│ ▼ │
│ CanActivate │
│ │ │
│ ├── Denied → cancel │
│ │ (resolvers skipped) │
│ │ │
│ └── Allowed │
│ │ │
│ ▼ │
│ Resolve │
│ │ │
│ ▼ │
│ Component activates │
│ │
└──────────────────────────────────────────────┘
Visual: Error Handling
┌──────────────────────────────────────────────┐
│ ❌ Resolver throws │
│ │
│ return this.service.get(id); │
│ │
│ → Promise rejects │
│ → Navigation cancelled │
│ → NavigationError emitted │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ ✅ Resolver catches │
│ │
│ return this.service.get(id).catch(() => null);│
│ │
│ → Resolves to null │
│ → Navigation proceeds │
│ → Component receives null │
│ │
└──────────────────────────────────────────────┘
Visual: Resolver vs Guard
┌──────────────────────────────────────────────┐
│ Guard │
│ │
│ Purpose: decide │
│ Returns: boolean / UrlTree │
│ Denies navigation │
│ No data attached │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Resolver │
│ │
│ Purpose: fetch │
│ Returns: data │
│ Fails navigation on throw │
│ Data goes to route.data │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Order: guards first, resolvers after │
│ Denied → resolvers never run │
│ │
└──────────────────────────────────────────────┘
Visual: Typed Route Data
┌──────────────────────────────────────────────┐
│ route.data is Data (any-valued) │
│ │
│ data['user'] → any │
│ data['posts'] → any │
│ │
└──────────────────────────────────────────────┘
│
│ cast to interface
▼
┌──────────────────────────────────────────────┐
│ interface UserRouteData { │
│ user: User; │
│ posts: Post[]; │
│ title: string; │
│ } │
│ │
│ const d = route.snapshot.data as UserRouteData;│
│ d.user.name ← typed ✅ │
│ d.posts.length ← typed ✅ │
│ │
└──────────────────────────────────────────────┘
Visual: When to Use a Resolver
┌──────────────────────────────────────────────┐
│ Data required to render the page? │
│ │ │
│ ├── Yes ──► Resolver ✅ │
│ │ │
│ └── No │
│ │ │
│ └── Use loading state │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Fetch fast (< 200ms)? │
│ │ │
│ ├── Yes ──► Resolver is fine │
│ │ │
│ └── No ──► Prefer loading UI │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Multiple views share the data? │
│ │ │
│ └── Yes ──► Resolver is helpful │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Data is optional? │
│ │ │
│ └── Load in the component │
│ │
└──────────────────────────────────────────────┘
Visual: Data Sources
┌──────────────────────────────────────────────┐
│ Static config data │
│ │
│ data: { title: 'X', section: 'admin' } │
│ │
└──────────────────────────────────────────────┘
│
│ merged with
▼
┌──────────────────────────────────────────────┐
│ Resolved data │
│ │
│ resolve: { user: userResolver } │
│ │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ route.data │
│ │
│ { │
│ title: 'X', │
│ section: 'admin', │
│ user: { ... } │
│ } │
│ │
│ Single access point │
│ │
└──────────────────────────────────────────────┘
Visual: Decision Flow
┌──────────────────────────────────────────────┐
│ Need data on a route? │
│ │ │
│ ├── Static and known at config? │
│ │ └── data: { ... } │
│ │ │
│ └── Fetched from a service? │
│ └── resolve: { key: resolverFn }│
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Reading data? │
│ │ │
│ ├── One-time ──► snapshot.data │
│ │ │
│ ├── Reactive ──► route.data.pipe(...) │
│ │ │
│ └── Signal ──► toSignal(route.data) │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Error handling? │
│ │ │
│ ├── Fallback value ──► .catch(() => x) │
│ │ │
│ └── Cancel navigation ──► don't catch │
│ │
└──────────────────────────────────────────────┘
Summary
| Concept | Meaning |
|---|---|
| Resolver | Function providing data before route activates |
ResolveFn<T> | Functional resolver type |
Resolve<T> | Class-based resolver interface |
data | Static route data |
resolve | Map of resolvers |
route.data | Merged static + resolved data |
| Order | Guards → Resolvers → Component |
| Return types | Sync, promise, observable |
Key takeaways:
- A resolver fetches data before the route activates — no flicker, no loading state
ResolveFn<T>is the modern functional form — a function withinject()Resolve<T>is the legacy class-based interface — still works- Resolvers attach via the
resolvemap on the route config - The key in
resolvebecomes the property inroute.data - Static
datacarries config-time values like titles and flags - Data is inherited — parent data merges into child data
- Read data from
route.data— snapshot for one-time, observable for reactive,toSignalfor signals - Resolvers run after guards — denied routes never fetch
- Errors cancel navigation unless caught inside the resolver
- Catch with a fallback —
.catch(() => null)— to keep navigation going - Resolvers run on every activation — cache in the service if needed
- Use resolvers for required data with fast fetches; use loading UI for slow fetches
- Type the data — cast to an interface or use a helper
- Test with
TestBed.runInInjectionContext— the resolver needs an injection context
Remember: Resolvers exist to make the component’s job simpler. Instead of handling loading, error, and ready states, the component just renders the data. The resolver fetches, the router waits, and the component receives what it needs. Combine resolvers with static data for titles and flags, read everything from route.data, and use the observable form when the route might be reused. Done right, the user never sees a spinner for data the route depends on — the navigation just takes a moment, then the page appears whole.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!