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 userid—'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:
| Source | Binds to input |
|---|---|
| Route params | Same-named input |
| Query params | Same-named input |
| Resolved data | Same-named input |
| Static route data | Same-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
@Inputproperties — 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 withcomputed.
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
ngOnChangesto detect changes. With signal inputs,computedandeffecthandle 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 inputuserId— computed fromidtab— query param, default'profile'title— static route datauser,posts— resolved datawithComponentInputBinding()— 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
| Source | Binds 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
| Form | Effect |
|---|---|
@Input() page = '1' | Default if missing |
page = input('1') | Same with signals |
input.required<T>() | Error if missing |
What Binds
| Source | Binds |
|---|---|
| Route params | ✅ |
| Query params | ✅ |
| Resolved data | ✅ |
| Static data | ✅ |
| Fragment | ❌ |
| Parent params | ❌ |
| Navigation events | ❌ |
| Children | ❌ |
What Doesn’t Bind
| Source | Use instead |
|---|---|
| Fragment | ActivatedRoute.fragment |
| Parent params | ActivatedRoute.parent |
| All query values | route.queryParamMap.getAll |
| Navigation events | Router.events |
Component Reuse on Change
| Change | Effect |
|---|---|
| Same route, new param | Inputs updated |
| Same route, new query | Inputs updated |
| Same route, new resolved | Inputs updated |
Signal vs Decorator
| Aspect | Decorator | Signal |
|---|---|---|
| Form | @Input() | input() |
| Read | this.value | this.value() |
| Default | = value | input(value) |
| Required | !: | input.required<T>() |
| Reacts via | ngOnChanges | computed/effect |
| Recommended | Legacy | Modern |
Common Patterns
| Pattern | Code |
|---|---|
| Route param input | @Input() id!: string |
| Query param default | @Input() page = '1' |
| Signal conversion | id = input.required<string>(); userId = computed(...) |
| Resolved data | @Input() user!: User |
| Static data | @Input() title = '' |
| Combined sources | All of the above in one component |
Error Cases
| Issue | Cause |
|---|---|
| Input undefined | No matching param |
| Wrong type | String from URL |
| No binding | withComponentInputBinding() missing |
| Name mismatch | Input name differs from data key |
Testing
TestBed.configureTestingModule({
providers: [provideRouter(routes, withComponentInputBinding())]
});
const fixture = TestBed.createComponent(UserDetailComponent);
fixture.componentRef.setInput('id', '42');
With withComponentInputBinding()
| Before | After |
|---|---|
route.snapshot.paramMap.get('id') | @Input() id |
route.snapshot.data['user'] | @Input() user |
route.snapshot.queryParamMap.get('page') | @Input() page |
Subscriptions to paramMap | Reactive signal inputs |
Migration
| Old | New |
|---|---|
Inject ActivatedRoute | Declare inputs |
Subscribe to paramMap | input() + computed() |
Read snapshot.data | @Input() fields |
switchMap for params | Reactive 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
| Pitfall | Problem | Solution |
|---|---|---|
| Missing feature provider | No binding | withComponentInputBinding() |
| Type mismatch | String from URL | Convert with Number() |
| Fragment not binding | Uses ActivatedRoute | Add inject(ActivatedRoute) |
| Parent param not binding | Only current route’s params | Read parent explicitly |
| Signal required input missing | Runtime error | Ensure route has the source |
| Name mismatch | Silent no-op | Match input to route data name |
| Forgot default | undefined for missing param | @Input() page = '1' |
| Using decorator input with signal compute | Mixing styles | Pick signals or decorators |
| Binding route param to a number | Compile error | @Input() id: string |
| Testing without setInput | Input undefined | fixture.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
| Concept | Meaning |
|---|---|
| Component input binding | Router sets component inputs from route data |
withComponentInputBinding() | Enables the feature |
| Bound sources | Params, query params, resolved data, static data |
| Matching | By name |
| Signal inputs | Work the same way |
| Conversion | Strings from params — convert with computed or setter |
| Not bound | Fragment, parent params, navigation events |
Key takeaways:
- Component input binding lets the router set a component’s inputs from route data
- Enable with
withComponentInputBinding()inprovideRouter - Route params bind by name —
:idbecomes@Input() id - Query params bind by name —
?page=2becomes@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 acomputedor setter - Signal inputs —
input()— work identically and pair withcomputed/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
ActivatedRoutewhen 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!