| |

Angular 28 🅰️ Route-Level Data and Component Input Binding

For years, routed components had one way to read their data: inject ActivatedRoute and pull values from route.snapshot, route.data, route.paramMap. That works, but it couples every routed component to the router — you can’t test it without a mock, and you can’t reuse it outside a route. Angular 16 introduced component input binding: the router can set the component’s @Input properties directly from route params, query params, and resolved data. Suddenly, a routed component is just a component with inputs. No ActivatedRoute, no snapshot, no observable subscription for the common case.

Key point: With withComponentInputBinding() enabled, the router binds route data to the component’s inputs by name. A route param :id populates an id input. A query param page populates a page input. A resolved user populates a user input. The component declares what it needs, and the router provides it. That’s the full idea — the router stops being an injected service and starts being an input source, like any parent component.


What component input binding is

Component input binding makes the router set a component’s inputs from route data.

@Component({
  selector: 'app-user-detail',
  standalone: true,
  template: `<h1>{{ user.name }}</h1>`
})
export class UserDetailComponent {
  @Input() user!: User;
  @Input() id!: string;
  @Input() tab = 'profile';
}

With input binding enabled, navigating to /users/42?tab=posts sets:

  • user — the resolved user
  • id'42' (the route param)
  • tab'posts' (the query param)

No ActivatedRoute, no paramMap, no subscription. The component receives what it declares.

What the router binds:

SourceBinds to input
Route paramsSame-named input
Query paramsSame-named input
Resolved dataSame-named input
Static route dataSame-named input

What the router doesn’t bind:

  • Inputs with no matching source — left at their default
  • Inputs the component doesn’t declare
  • Route data under names the component doesn’t have inputs for

Why it matters: A routed component is now a normal component. It takes inputs. Any parent — the router or a test — can provide them. No coupling to ActivatedRoute.

Why “component input binding”: The router binds its data to the component’s inputs. The name describes both ends — the router’s data and the component’s inputs.

Why this took until Angular 16: Component input binding requires knowing a component’s inputs at the route level. With standalone components and the modern injector, the router can inspect the component and set inputs before the first change detection. Earlier architectures made this harder. The feature landed when the plumbing was ready.


Enabling input binding

Input binding is off by default. Turn it on with withComponentInputBinding().

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding())
  ]
});

That’s the whole setup. Every routed component can now declare inputs that match route data.

What withComponentInputBinding does:

  • Tells the router to bind route data to component inputs
  • Applies to every routed component
  • Binds params, query params, resolved data, static data — all by name

Where it goes: In the provideRouter feature list, alongside other router features.

provideRouter(
  routes,
  withComponentInputBinding(),
  withPreloading(PreloadAllModules)
)

Why it’s opt-in: It changes how routed components receive data. Existing components that use ActivatedRoute still work — but the new mechanism is more idiomatic. Making it opt-in avoids surprising existing code.

Why it’s worth enabling: It makes routed components plain components. Inputs are the Angular way to receive data. Enabling input binding unifies the two patterns.

The alternative without input binding: A routed component injects ActivatedRoute, reads data in ngOnInit, and stores it. That works but couples the component to the router.

Why “binding” and not “injection”: Input binding sets the component’s @Input properties — the same mechanism a parent template uses. That’s the binding system. The router just happens to be the one providing the values. Keeping the term consistent with the rest of Angular matters.


Binding route params

A route param with a matching input name is bound automatically.

// Route: { path: 'users/:id', component: UserDetailComponent }
@Component({ /* ... */ })
export class UserDetailComponent {
  @Input() id!: string;   // receives '42' when URL is /users/42
}

The param name id matches the input name id. The value is always a string — route params come from the URL.

Multiple params:

// Route: { path: 'users/:userId/posts/:postId', component: PostComponent }
@Component({ /* ... */ })
export class PostComponent {
  @Input() userId!: string;
  @Input() postId!: string;
}

Each param binds to its matching input.

Converting types: Params are strings. Convert in a setter, ngOnInit, or a computed.

export class UserDetailComponent {
  private _id = 0;

  @Input()
  set id(value: string) {
    this._id = Number(value);
  }

  get id(): number {
    return this._id;
  }
}

The setter converts once. The component reads this.id as a number.

With signal inputs:

export class UserDetailComponent {
  id = input.required<string>();
  userId = computed(() => Number(this.id()));
}

Signal inputs work the same way — the router sets them before change detection.

Why params bind by name: The route param name is the input name. No configuration, no mapping. The convention is the rule.

Why params are strings: The URL is text. TypeScript can’t know a segment should be a number. The component converts — with a setter, a computed, or in ngOnInit.

Why conversion is the component’s job: The router can’t infer the intended type from a URL segment. '42' might be an ID, a count, or a year. The component knows what it expects, so it converts. Signal inputs make this clean with computed.


Binding query params

Query params bind the same way — by matching name.

// URL: /users?page=2&sort=name
@Component({ /* ... */ })
export class UsersComponent {
  @Input() page = '1';        // receives '2'
  @Input() sort = 'name';     // receives 'name'
}

Each query param binds to the input with the same name.

Defaults: Inputs have their own defaults. If the query param is missing, the input keeps its default.

@Input() page = '1';   // '1' if ?page is missing
@Input() sort = 'name';

That’s cleaner than route.snapshot.queryParamMap.get('page') ?? '1'. The default lives on the input.

Updating on query change: Navigate to the same route with a new ?page=3 — Angular reuses the component and updates the input. There’s no need for an observable subscription.

// URL: /users?page=1 → /users?page=2
// Component is reused, `page` input updated to '2'

Why this is a big improvement: Query params changed frequently but required manual subscription. Now they’re inputs. When they change, the input changes. Declarative.

Multiple values: A query param with multiple values (?tag=a&tag=b) can’t bind to a single input. Use ActivatedRoute.queryParamMap.getAll('tag') if you need all values.

Why defaults matter: Query params are optional. The input’s default handles absence. No ?? chain, no null checks.

Why reuse the component on query change: Same route, same component, different query — Angular keeps the instance and updates the inputs. That’s efficient and matches the declarative model. The component reacts to the input change like any other input.


Binding resolved data

Resolved data binds by its key.

// Route:
{
  path: 'users/:id',
  component: UserDetailComponent,
  resolve: { user: userResolver }
}
@Component({ /* ... */ })
export class UserDetailComponent {
  @Input() user!: User;   // receives the resolved user
}

The resolver’s key user matches the input user. The router binds the resolved value.

Why this is powerful: A resolver that used to require route.data['user'] in the component now just delivers to an input. The component declares @Input() user and receives the data.

Combining params and resolved data:

{
  path: 'users/:id',
  component: UserDetailComponent,
  resolve: { user: userResolver }
}
export class UserDetailComponent {
  @Input() id!: string;     // route param
  @Input() user!: User;     // resolved
}

Both land in the component. The component reads them as inputs.

Why the key must match: The router binds by name. A resolver key user becomes an input named user. Rename the key without renaming the input, and the binding fails.

Why binding resolved data is the cleanest pattern: Resolvers are for data the component needs. Input binding puts that data on an input. The component’s contract is its inputs — no router coupling, no snapshot, no subscription.

Why resolver + input binding is idiomatic: The resolver fetches; the router binds. The component just has inputs. Any parent providing those inputs works — a test, a wrapper, another route. That’s the decoupling the binding is for.


Binding static data

Static route data binds by name too.

{
  path: 'settings',
  component: SettingsComponent,
  data: { title: 'Settings', requiresAuth: true }
}
export class SettingsComponent {
  @Input() title = '';         // receives 'Settings'
  @Input() requiresAuth = false; // receives true
}

Static data is bound like any other source.

Why this is useful: Titles, breadcrumbs, feature flags — all become inputs. The component declares what it needs; the route config provides it.

Combining all sources:

{
  path: 'users/:id',
  component: UserDetailComponent,
  data: { title: 'User Detail' },
  resolve: { user: userResolver }
}
export class UserDetailComponent {
  @Input() id!: string;          // route param
  @Input() title = '';           // static data
  @Input() user!: User;          // resolved
}

All three sources land in the component as inputs. The component doesn’t care which came from where.

Why the unification matters: Route data has four sources — params, query params, resolved, static. Input binding treats them all the same. The component declares inputs; the router fills them from whatever source has the matching name.

Why this is elegant: The component’s inputs describe what the route needs. The route’s config provides it. The two are linked by name — no mapping, no helpers.

Why no explicit mapping exists: A mapping layer would add boilerplate. Matching by name is simple and predictable. The cost is that the name must match — rename one and the binding breaks silently. Typed checks catch this at review time, and the convention is easy to follow.


Signal inputs and input binding

Signal inputs work identically — the router sets them before change detection.

@Component({ /* ... */ })
export class UserDetailComponent {
  id = input.required<string>();
  user = input<User | null>(null);
}

The router sets id and user as signals. Reading them inside computed or effect reacts to changes.

Reading with computed:

export class UserDetailComponent {
  id = input.required<string>();
  userId = computed(() => Number(this.id()));
}

userId recalculates when id changes — which happens when the URL changes.

Reading with effect:

export class UserDetailComponent {
  user = input<User | null>(null);

  constructor() {
    effect(() => {
      const u = this.user();
      if (u) console.log('loaded', u.name);
    });
  }
}

The effect runs when user changes.

Why signal inputs pair naturally: Input binding sets the input. Signal inputs are reactive. Together, route changes propagate through signals — no subscription, no ngOnChanges.

Why this is the modern default: Signal inputs and input binding are both new. They fit each other — the router sets a signal, the component reacts declaratively.

The advantage over decorator inputs: Decorator inputs are plain properties. You react with ngOnChanges. Signal inputs compose with computed and effect — the modern reactive model.

Why signals matter here: With decorator inputs, you need ngOnChanges to detect changes. With signal inputs, computed and effect handle it. The router setting the input is invisible — the reactive graph just works.


What input binding doesn’t cover

Not everything fits the input-binding model.

Route snapshots for parent params:

// Input binding binds the current route's params.
// For the parent's params, you still need ActivatedRoute.

Reactive queries beyond params:

// Only the current route's params, query params, and data bind.
// Events, children, fragments — no.

Complex transformations:

// Input binding gives raw strings for params.
// Conversion is the component's job.

Fragment:

// Fragment (#section) does not bind to an input.
// Use ActivatedRoute if you need it.

The general rule: Input binding covers the common case — params, query params, resolved data, static data, by name. Anything else uses ActivatedRoute.

Why not everything: Input binding is a simple name-matching mechanism. Complex cases — reading the fragment, watching navigation events, accessing the parent — need the full router API. The binding handles the 80% case cleanly.

When to mix: Use input binding for what it covers; inject ActivatedRoute for the rest. Both can coexist in the same component.

Why the limit is a feature: By covering only the common sources, input binding keeps a simple model. If it tried to handle everything, it would need the whole router API surface. The clean separation keeps each mechanism focused.


A full example

A user detail route with input binding for every data source.

// ============================================
// TYPES
// ============================================

export interface User {
  id: number;
  name: string;
  email: string;
}

export interface Post {
  id: number;
  title: string;
}

// ============================================
// SERVICE
// ============================================

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@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 } from '@angular/router';

export const userResolver: ResolveFn<User | null> = (route) => {
  const id = Number(route.paramMap.get('id'));
  return inject(UserService).getUser(id).catch(() => null);
};

export const postsResolver: ResolveFn<Post[]> = (route) => {
  const id = Number(route.paramMap.get('id'));
  return inject(UserService).getUserPosts(id).catch(() => []);
};

// ============================================
// COMPONENT — INPUT BINDING
// ============================================

import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h1>{{ title() }}</h1>

    @if (user()) {
      <h2>{{ user()!.name }}</h2>
      <p>{{ user()!.email }}</p>

      <p>Showing {{ tab() }} tab</p>

      @if (tab() === 'posts') {
        <h3>Posts ({{ posts().length }})</h3>
        <ul>
          @for (post of posts(); track post.id) {
            <li>{{ post.title }}</li>
          }
        </ul>
      }
    } @else {
      <p>User not found.</p>
    }
  `
})
export class UserDetailComponent {
  // Route param
  id = input.required<string>();
  userId = computed(() => Number(this.id()));

  // Query param — with default
  tab = input('profile');

  // Static route data
  title = input('User Detail');

  // Resolved data
  user = input<User | null>(null);
  posts = input<Post[]>([]);
}

// ============================================
// ROUTES
// ============================================

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'users/:id',
    component: UserDetailComponent,
    data: { title: 'User Profile' },
    resolve: {
      user: userResolver,
      posts: postsResolver
    }
  }
];

// ============================================
// BOOTSTRAP
// ============================================

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding())
  ]
});

What this shows:

  • id — route param, bound to a signal input
  • userId — computed from id
  • tab — query param, default 'profile'
  • title — static route data
  • user, posts — resolved data
  • withComponentInputBinding() — enables binding

Every route data source is bound to a signal input. The component has no ActivatedRoute injection, no snapshot, no subscription.

Why this shape: It’s the modern routed component. Inputs describe what the route provides. The router fills them. The component reads signals. No coupling to the router at all.


Complete Example Session

# ============================================
# PART 1: ENABLE INPUT BINDING
# ============================================

cat > main.ts << 'EOF'
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { AppComponent } from './app.component';
import { routes } from './app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding())
  ]
});
EOF

npx tsc --noEmit main.ts
# (no errors)

# ============================================
# PART 2: BIND ROUTE PARAM
# ============================================

cat > user-detail.ts << 'EOF'
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  template: `<p>User ID: {{ id }}</p>`
})
export class UserDetailComponent {
  @Input() id!: string;
}
EOF

npx tsc --noEmit user-detail.ts
# (no errors)

# ============================================
# PART 3: BIND QUERY PARAM
# ============================================

cat > users-list.ts << 'EOF'
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-users-list',
  standalone: true,
  template: `<p>Page {{ page }}, Sort {{ sort }}</p>`
})
export class UsersListComponent {
  @Input() page = '1';
  @Input() sort = 'name';
}
EOF

npx tsc --noEmit users-list.ts
# (no errors)

# ============================================
# PART 4: BIND RESOLVED DATA
# ============================================

cat > with-resolver.ts << 'EOF'
import { Component, Input } from '@angular/core';

interface User {
  id: number;
  name: string;
}

@Component({
  selector: 'app-resolved',
  standalone: true,
  template: `<h1>{{ user.name }}</h1>`
})
export class ResolvedComponent {
  @Input() user!: User;
}
EOF

npx tsc --noEmit with-resolver.ts
# (no errors)

# ============================================
# PART 5: BIND STATIC DATA
# ============================================

cat > static.ts << 'EOF'
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-settings',
  standalone: true,
  template: `<h1>{{ title }}</h1>`
})
export class SettingsComponent {
  @Input() title = '';
  @Input() requiresAuth = false;
}
EOF

npx tsc --noEmit static.ts
# (no errors)

# ============================================
# PART 6: SIGNAL INPUTS
# ============================================

cat > signals.ts << 'EOF'
import { Component, input, computed } from '@angular/core';

@Component({
  selector: 'app-signal-detail',
  standalone: true,
  template: `<p>Numeric ID: {{ userId() }}</p>`
})
export class SignalDetailComponent {
  id = input.required<string>();
  userId = computed(() => Number(this.id()));
}
EOF

npx tsc --noEmit signals.ts
# (no errors)

# ============================================
# PART 7: COMBINED SOURCES
# ============================================

cat > combined.ts << 'EOF'
import { Component, Input } from '@angular/core';

interface User {
  id: number;
  name: string;
}

@Component({
  selector: 'app-combined',
  standalone: true,
  template: `
    <h1>{{ title }}</h1>
    <p>ID: {{ id }}</p>
    <p>Tab: {{ tab }}</p>
    @if (user) {
      <p>Name: {{ user.name }}</p>
    }
  `
})
export class CombinedComponent {
  @Input() id!: string;        // route param
  @Input() tab = 'profile';    // query param
  @Input() title = '';         // static data
  @Input() user: User | null = null;  // resolved
}
EOF

npx tsc --noEmit combined.ts
# (no errors)

# ============================================
# PART 8: SUMMARY
# ============================================

cat << 'EOF'
Component input binding:

  Enable:
    provideRouter(routes, withComponentInputBinding())

  Bind by name:
    Route param       → @Input() id
    Query param       → @Input() page
    Resolved data     → @Input() user
    Static data       → @Input() title

  Signal inputs:
    id = input.required<string>();
    user = input<User | null>(null);

  Defaults:
    @Input() page = '1';
    tab = input('profile');

  Values are strings for params and query params.
  Convert in a computed or setter.
EOF

Quick Reference

Enabling Input Binding

provideRouter(routes, withComponentInputBinding())

Data Sources → Inputs

SourceBinds to
Route param :id@Input() id
Query param ?page=2@Input() page
Resolved user@Input() user
Static data title@Input() title

Decorator Inputs

@Input() id!: string;
@Input() page = '1';
@Input() user: User | null = null;

Signal Inputs

id = input.required<string>();
page = input('1');
user = input<User | null>(null);

Conversion

// Decorator
private _id = 0;

@Input()
set id(value: string) { this._id = Number(value); }
get id(): number { return this._id; }

// Signal
id = input.required<string>();
userId = computed(() => Number(this.id()));

Defaults

FormEffect
@Input() page = '1'Default if missing
page = input('1')Same with signals
input.required<T>()Error if missing

What Binds

SourceBinds
Route params
Query params
Resolved data
Static data
Fragment
Parent params
Navigation events
Children

What Doesn’t Bind

SourceUse instead
FragmentActivatedRoute.fragment
Parent paramsActivatedRoute.parent
All query valuesroute.queryParamMap.getAll
Navigation eventsRouter.events

Component Reuse on Change

ChangeEffect
Same route, new paramInputs updated
Same route, new queryInputs updated
Same route, new resolvedInputs updated

Signal vs Decorator

AspectDecoratorSignal
Form@Input()input()
Readthis.valuethis.value()
Default= valueinput(value)
Required!:input.required<T>()
Reacts viangOnChangescomputed/effect
RecommendedLegacyModern

Common Patterns

PatternCode
Route param input@Input() id!: string
Query param default@Input() page = '1'
Signal conversionid = input.required<string>(); userId = computed(...)
Resolved data@Input() user!: User
Static data@Input() title = ''
Combined sourcesAll of the above in one component

Error Cases

IssueCause
Input undefinedNo matching param
Wrong typeString from URL
No bindingwithComponentInputBinding() missing
Name mismatchInput name differs from data key

Testing

TestBed.configureTestingModule({
  providers: [provideRouter(routes, withComponentInputBinding())]
});

const fixture = TestBed.createComponent(UserDetailComponent);
fixture.componentRef.setInput('id', '42');

With withComponentInputBinding()

BeforeAfter
route.snapshot.paramMap.get('id')@Input() id
route.snapshot.data['user']@Input() user
route.snapshot.queryParamMap.get('page')@Input() page
Subscriptions to paramMapReactive signal inputs

Migration

OldNew
Inject ActivatedRouteDeclare inputs
Subscribe to paramMapinput() + computed()
Read snapshot.data@Input() fields
switchMap for paramsReactive inputs

Best Practices

Do This:

// Enable input binding in bootstrap
provideRouter(routes, withComponentInputBinding())              // ✅

// Use signal inputs for modern code
id = input.required<string>();
user = input<User | null>(null);                                // ✅

// Declare inputs matching route data names
@Input() id!: string;                                           // ✅

// Use input defaults for optional query params
tab = input('profile');                                         // ✅

// Convert in a computed
userId = computed(() => Number(this.id()));                     // ✅

// React with effect or computed
effect(() => console.log(this.user()));                         // ✅

// Use setter for decorator inputs
@Input()
set id(value: string) { this._id = Number(value); }             // ✅

// Mix input binding with ActivatedRoute for uncovered cases
const fragment = inject(ActivatedRoute).snapshot.fragment;      // ✅

// Test with setInput
fixture.componentRef.setInput('user', mockUser);                // ✅

// Keep input names aligned with route data
// If route has :userId, input should be userId                  // ✅

Don’t Do This:

// Don't forget to enable input binding
provideRouter(routes)  // ⚠️  inputs won't bind                 // ⚠️

// Don't expect non-string params
@Input() id!: number;  // ⚠️  actually string                    // ⚠️

// Don't expect fragments to bind
@Input() section!: string;  // ⚠️  fragment doesn't bind         // ⚠️

// Don't bind parent params
// @Input() parentId won't receive the parent's :id             // ⚠️

// Don't duplicate the router and input binding
@Input() id!: string;
route = inject(ActivatedRoute);  // ⚠️  redundant for id          // ⚠️

// Don't forget required signal inputs
id = input.required<string>();
// If missing → runtime error                                   // ✅

// Don't expect two-way binding from inputs
// Inputs are one-way (router → component)                      // ⚠️

// Don't skip the conversion
const id = this.id;  // ⚠️  string, not number                    // ⚠️

// Don't use ngOnChanges with signal inputs
// Use effect or computed                                        // ⚠️

// Don't rename inputs without renaming route data
// Binding breaks silently                                        // ⚠️

Common Pitfalls

PitfallProblemSolution
Missing feature providerNo bindingwithComponentInputBinding()
Type mismatchString from URLConvert with Number()
Fragment not bindingUses ActivatedRouteAdd inject(ActivatedRoute)
Parent param not bindingOnly current route’s paramsRead parent explicitly
Signal required input missingRuntime errorEnsure route has the source
Name mismatchSilent no-opMatch input to route data name
Forgot defaultundefined for missing param@Input() page = '1'
Using decorator input with signal computeMixing stylesPick signals or decorators
Binding route param to a numberCompile error@Input() id: string
Testing without setInputInput undefinedfixture.componentRef.setInput

Real-World Examples

1. Enable input binding

provideRouter(routes, withComponentInputBinding())

2. Route param input

@Input() id!: string;

3. Signal route param

id = input.required<string>();

4. Query param with default

@Input() page = '1';

5. Signal query param

tab = input('profile');

6. Resolved data input

@Input() user!: User;

7. Signal resolved data

user = input<User | null>(null);

8. Static data input

@Input() title = '';

9. Combined sources

@Input() id!: string;
@Input() tab = 'profile';
@Input() title = '';
@Input() user: User | null = null;

10. Convert a param

userId = computed(() => Number(this.id()));

11. Setter conversion

@Input()
set id(value: string) { this._id = Number(value); }

12. Multiple route params

@Input() userId!: string;
@Input() postId!: string;

13. Optional input

@Input() sort = 'name';

14. Signal computed from input

id = input.required<string>();
displayId = computed(() => `#${this.id()}`);

15. React with effect

effect(() => {
  if (this.user()) console.log('loaded');
});

16. Mix with ActivatedRoute

fragment = inject(ActivatedRoute).snapshot.fragment;

17. Test with setInput

fixture.componentRef.setInput('user', mockUser);
fixture.detectChanges();

18. Navigate with query params

this.router.navigate([], {
  queryParams: { page: 2 },
  queryParamsHandling: 'merge',
  relativeTo: this.route
});

19. Route config with all sources

{
  path: 'users/:id',
  component: UserDetailComponent,
  data: { title: 'User Detail' },
  resolve: { user: userResolver }
}

20. Full component

export class UserDetailComponent {
  id = input.required<string>();
  tab = input('profile');
  title = input('');
  user = input<User | null>(null);
  userId = computed(() => Number(this.id()));
}

Visual: Input Binding Flow

┌──────────────────────────────────────────────┐
│  URL: /users/42?tab=posts                    │
│                                              │
└──────────────────────────────────────────────┘
                  │
                  ▼
┌──────────────────────────────────────────────┐
│  Route:                                      │
│  {                                           │
│    path: 'users/:id',                        │
│    component: UserDetailComponent,           │
│    data: { title: 'User' },                  │
│    resolve: { user: userResolver }           │
│  }                                           │
│                                              │
└──────────────────────────────────────────────┘
                  │
                  │  router binds
                  ▼
┌──────────────────────────────────────────────┐
│  Component inputs:                           │
│  ┌────────────────────────────────────────┐ │
│  │  id: '42'                              │ │
│  │  tab: 'posts'                          │ │
│  │  title: 'User'                         │ │
│  │  user: <resolved>                      │ │
│  └────────────────────────────────────────┘ │
│                                              │
└──────────────────────────────────────────────┘
                  │
                  ▼
┌──────────────────────────────────────────────┐
│  Component renders with everything set       │
│                                              │
└──────────────────────────────────────────────┘

Visual: Data Sources to Inputs

┌──────────────────────────────────────────────┐
│  Route params:                               │
│  /users/:id                                  │
│       │                                      │
│       └──► @Input() id                       │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Query params:                               │
│  /users?page=2                               │
│       │                                      │
│       └──► @Input() page                     │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Static data:                                │
│  data: { title: 'X' }                        │
│       │                                      │
│       └──► @Input() title                    ││                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Resolved data:                              │
│  resolve: { user: ... }                      │
│       │                                      │
│       └──► @Input() user                     │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  All bind by name                            │
│                                              │
└──────────────────────────────────────────────┘

Visual: Without vs With Input Binding

┌──────────────────────────────────────────────┐
│  Without:                                    │
│                                              │
│  export class C implements OnInit {          │
│    private route = inject(ActivatedRoute);   │
│    user: User | null = null;                 │
│                                              │
│    ngOnInit() {                              │
│      this.route.data.subscribe(d => {        │
│        this.user = d['user'];                │
│      });                                     │
│      this.route.paramMap.subscribe(p => {    │
│        this.id = p.get('id');                │
│      });                                     │
│    }                                         │
│  }                                           │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  With input binding:                         │
│                                              │
│  export class C {                            │
│    id = input.required<string>();            │
│    user = input<User | null>(null);          │
│  }                                           │
│                                              │
│  No injection, no subscriptions              │
│                                              │
└──────────────────────────────────────────────┘

Visual: Signal Input Reactivity

┌──────────────────────────────────────────────┐
│  id = input.required<string>()               │
│       │                                      │
│       │  URL changes /users/42 → /users/43   │
│       ▼                                      │
│  input updates                               │
│       │                                      │
│       ▼                                      │
│  computed and effect rerun                   │
│       │                                      │
│       ▼                                      │
│  Template updates                            │
│                                              │
└──────────────────────────────────────────────┘

Visual: What Binds, What Doesn’t

┌──────────────────────────────────────────────┐
│  ✅ Binds:                                   │
│                                              │
│  • Route params (:id)                        │
│  • Query params (?page=2)                    │
│  • Resolved data                             │
│  • Static route data                         │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  ❌ Doesn't bind:                            │
│                                              │
│  • Fragment (#section)                       │
│  • Parent route params                       │
│  • Navigation events                         │
│  • Route snapshot's URL segments             │
│                                              │
│  → Use ActivatedRoute for these              │
│                                              │
└──────────────────────────────────────────────┘

Visual: Decorator vs Signal

┌──────────────────────────────────────────────┐
│  Decorator input:                            │
│                                              │
│  @Input() id!: string;                       │
│  @Input() user: User | null = null;          │
│                                              │
│  read: this.id, this.user                    │
│  react: ngOnChanges                          │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Signal input:                               │
│                                              │
│  id = input.required<string>();              │
│  user = input<User | null>(null);            │
│                                              │
│  read: this.id(), this.user()                │
│  react: computed, effect                     │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Both bind from the route                    │
│  Pick one style for consistency              │
│                                              │
└──────────────────────────────────────────────┘

Visual: Component Reuse

┌──────────────────────────────────────────────┐
│  URL: /users/1?tab=profile                   │
│  Component created                           │
│  id = '1'                                    │
│  tab = 'profile'                             │
│                                              │
└──────────────────────────────────────────────┘
                  │
                  │  navigate /users/1?tab=posts
                  ▼
┌──────────────────────────────────────────────┐
│  Component reused                            │
│  id = '1'  (unchanged)                       │
│  tab = 'posts'  ← updated                    │
│                                              │
│  No recreate — signal emits                  │
│  computed/effect react                       │
│                                              │
└──────────────────────────────────────────────┘
                  │
                  │  navigate /users/2?tab=posts
                  ▼
┌──────────────────────────────────────────────┐
│  Component reused                            │
│  id = '2'  ← updated                         │
│  tab = 'posts'                               │
│                                              │
│  signals react                               │
│                                              │
└──────────────────────────────────────────────┘

Visual: Decision Flow

┌──────────────────────────────────────────────┐
│  Reading route data?                         │
│       │                                      │
│       ├── In the common sources?             │
│       │      │                               │
│       │      ├── Route param ──► @Input / input()│
│       │      │                               │
│       │      ├── Query param ──► @Input      │
│       │      │                               │
│       │      ├── Resolved data ──► @Input    │
│       │      │                               │
│       │      └── Static data ──► @Input      │
│       │                                      │
│       └── Not covered?                       │
│              └── Use ActivatedRoute          │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Decorator or signal input?                  │
│       │                                      │
│       ├── New code ──► signal input()        │
│       │                                      │
│       └── Legacy code ──► @Input()           │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Need to react to changes?                   │
│       │                                      │
│       ├── Derived value ──► computed         │
│       │                                      │
│       └── Side effect ──► effect             │
│                                              │
└──────────────────────────────────────────────┘

Summary

ConceptMeaning
Component input bindingRouter sets component inputs from route data
withComponentInputBinding()Enables the feature
Bound sourcesParams, query params, resolved data, static data
MatchingBy name
Signal inputsWork the same way
ConversionStrings from params — convert with computed or setter
Not boundFragment, parent params, navigation events

Key takeaways:

  • Component input binding lets the router set a component’s inputs from route data
  • Enable with withComponentInputBinding() in provideRouter
  • Route params bind by name — :id becomes @Input() id
  • Query params bind by name — ?page=2 becomes @Input() page
  • Resolved data binds by resolver key — resolve: { user } becomes @Input() user
  • Static data binds by key — data: { title } becomes @Input() title
  • All params are strings — convert with Number() in a computed or setter
  • Signal inputsinput() — work identically and pair with computed/effect
  • Fragment, parent params, and navigation events don’t bind — use ActivatedRoute
  • Component reuse updates inputs rather than recreating — signals react
  • Input defaults replace ?? chains for optional query params
  • Mix input binding and ActivatedRoute when you need both
  • Test with fixture.componentRef.setInput()
  • Match names — input name must match the data key or nothing binds

Remember: Input binding turns the router from a service you inject into a parent you receive inputs from. Params, query params, resolved data, static data — all become inputs by name. The component declares what it needs; the router provides it. No ActivatedRoute, no snapshot, no subscription. Signal inputs make the whole thing reactive — computed and effect handle changes. Enable withComponentInputBinding(), name your inputs to match your route data, and your routed components become plain components. That’s the modern pattern.


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!