| |

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 to paramMap, queryParamMap, data, and parent
  • state: 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:

AspectFunctionalClass
FormFunctionClass with resolve method
Dependenciesinject()Constructor injection
BoilerplateNone@Injectable, implements
TestabilityDirectNeeds TestBed or manual
Modern defaultLegacy

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 an implements clause. 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:

KeyUse
titlePage title
breadcrumbBreadcrumb label
requiresAuthMetadata for guards
preloadCustom preloading flag
animationAnimation 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.data isn’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 is Data — effectively Record<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.

AspectGuardResolver
PurposeDecideFetch
ReturnsBoolean, UrlTreeData
Cancels navigationOn error
Attaches to routeNo✅ (route.data)
Runs firstAfter guards

The order:

  1. Guards run. Any denial cancels navigation — resolvers don’t run.
  2. Resolvers run. They fetch data.
  3. 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, returns null on error
  • userPostsResolver — fetches posts, returns [] on error
  • Static datatitle from the route config
  • Component — reads route.data reactively, 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

FormMeaning
ResolveFn<T>Functional resolver
Resolve<T>Class-based interface
(route, state) => TSync 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

MethodResult
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

LevelData
ParentIts data
ChildParent’s merged with its own
GrandchildAll ancestors merged

Reading Parent Data

route.parent?.snapshot.data['key']

Functional vs Class

AspectFunctionalClass
TypeResolveFn<T>Resolve<T>
Depsinject()Constructor
UseModernLegacy
FileOften one functionFull class

Resolver Order

StepRuns
1Guards
2Resolvers
3Activate component

Guards deny → resolvers skip.

Error Handling

ApproachBehavior
ThrowNavigation fails
.catch(() => null)Navigation proceeds
.catch(() => [])Empty array fallback

Common Resolver Patterns

PatternReturns
Fetch onePromise<T | null>
Fetch manyPromise<T[]>
First valueObservable<T>
Complex combinationforkJoin

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

ErrorCause
NavigationErrorResolver threw
Cannot read propertyData key missing
Any on dataData is untyped
Infinite loadingResolver never resolves

Data vs Resolver

AspectStatic DataResolver
SourceConfigFunction
FetchesNoYes
AsyncNoYes
Available at config time
Merged from parent
Cancels on errorNoYes

When to Use a Resolver

SituationResolver?
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

PitfallProblemSolution
Slow resolverBlank screen during navigationUse loading UI instead
Missing resolve keydata['user'] undefinedMatch the key in the route
Unhandled errorNavigation fails silentlyCatch inside the resolver
Snapshot on reuseStale dataSubscribe to route.data
Missing as castany typeDefine a route data interface
Forgot parent dataChild doesn’t see ancestorRead route.parent.data
Resolver returns undefinedNo dataReturn a value or throw
No cachingRefetches every timeCache in the service
Confusing data and resolverWrong toolStatic = config; resolver = fetch
Forgetting to testSilent failuresTest 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

ConceptMeaning
ResolverFunction providing data before route activates
ResolveFn<T>Functional resolver type
Resolve<T>Class-based resolver interface
dataStatic route data
resolveMap of resolvers
route.dataMerged static + resolved data
OrderGuards → Resolvers → Component
Return typesSync, 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 with inject()
  • Resolve<T> is the legacy class-based interface — still works
  • Resolvers attach via the resolve map on the route config
  • The key in resolve becomes the property in route.data
  • Static data carries 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, toSignal for 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!