Forward route details to inputs of routed components
What’s discussed here:
- Current behavior
- Angular v16 changes
- Usage patterns
- Migration path to the new API
- Testing strategy
- Potential pitfalls
In typical Angular apps, the Router is your primary tool for mapping distinct URLs to distinct views.
That same URL also dictates what data gets fetched, determined by both path parameters and query parameters.
With Angular v16, a fresh capability arrives that streamlines how components access route-derived data, cutting down on boilerplate considerably.
How it functions today
Imagine a routes array structured as follows:
const routes: Routes = [
{
path: "search",
component: SearchComponent,
},
];
To populate a search form, the component must extract the values from the query parameters.
Consider a URL such as: http://localhost:4200/search?q=Angular;
@Component({})
export class SearchComponent implements OnInit {
// here we inject the ActivatedRoute class that contains info about our current route
private route = inject(ActivatedRoute);
query$ = this.route.queryParams.pipe(map(queryParams) => queryParams['q']);
ngOnInit() {
this.query$.subscribe(query => { // do something with the query });
}
}
The ActivatedRoute service must be injected to reach query params, but that's not all. The same approach works for path params, data, and even resolved data — the example below walks through each of these options.
const routes: Routes = [
{
path: "search/:id",
component: SearchComponent,
data: { title: "Search" },
resolve: { searchData: SearchDataResolver }
},
];
@Component({})
export class SearchComponent implements OnInit {
private route = inject(ActivatedRoute);
query$ = this.route.queryParams.pipe(map(queryParams) => queryParams['q']);
id$ = this.route.params.pipe(map(params) => params['id']);
title$ = this.route.data.pipe(map(data) => data['title']);
searchData$ = this.route.data.pipe(map(data) => data['searchData']);
ngOnInit() {
this.query$.subscribe(query => { // do something with the query });
this.id$.subscribe(id => { // do something with the id });
this.title$.subscribe(title => { // do something with the title });
this.searchData$.subscribe(searchData => { // do something with the searchData });
}
}
How it will work in Angular v16
Angular v16 introduces a feature that streamlines how components access route data, greatly reducing complexity.
Route information can now be directly bound to component inputs, eliminating the need to inject the ActivatedRoute service.
const routes: Routes = [
{
path: "search",
component: SearchComponent,
},
];
@Component({})
export class SearchComponent implements OnInit {
/*
We can use the same name as the query param, for example 'query'
Example url: http://localhost:4200/search?query=Angular
*/
@Input() query?: string; // we can use the same name as the query param
/*
Or we can use a different name, for example 'q', and then we can use the @Input('q')
Example url: http://localhost:4200/search?q=Angular
*/
@Input('q') queryParam?: string; // we can also use a different name
ngOnInit() {
// do something with the query
}
}
In addition, the path-related parameters, static data, and resolved data are all transferable to the component’s inputs through binding.
const routes: Routes = [
{
path: "search/:id",
component: SearchComponent,
data: { title: "Search" },
resolve: { searchData: SearchDataResolver }
},
];
@Component({})
export class SearchComponent implements OnInit {
@Input() query?: string; // this will come from the query params
@Input() id?: string; // this will come from the path params
@Input() title?: string; // this will come from the data
@Input() searchData?: any; // this will come from the resolved data
ngOnInit() {
// do something with the query
// do something with the id
// do something with the title
// do something with the searchData
}
}
Naturally, we are free to assign any names we like to those inputs:
const routes: Routes = [
{
path: "search/:id",
component: SearchComponent,
data: { title: "Search" },
resolve: { searchData: SearchDataResolver }
},
];
@Component({})
export class SearchComponent implements OnInit {
@Input() query?: string;
@Input('id') pathId?: string;
@Input('title') dataTitle?: string;
@Input('searchData') resolvedData?: any;
ngOnInit() {
// do something with the query
// do something with the pathId
// do something with the dataTitle
// do something with the resolvedData
}
}
How to use it
Activating this capability requires turning it on within the RouterModule:
@NgModule({
imports: [
RouterModule.forRoot([], {
//... other features
bindToComponentInputs: true // <-- enable this feature
})
],
})
export class AppModule {}
With a standalone app, the same setup works as follows:
bootstrapApplication(App, {
providers: [
provideRouter(routes,
//... other features
withComponentInputBinding() // <-- enable this feature
)
],
});
How to migrate to the new api
When a component relies on the ActivatedRoute service, here’s how to shift it to the new approach:
- Strip out the
ActivatedRoutefrom the component’s constructor. - Decorate the properties tied to route data with the
@Input()decorator. - Turn on the
bindToComponentInputsoption in eitherRouterModuleor theprovideRouterfunction.
Consider a before-and-after example for path parameters, where the URL is http://localhost:4200/search/123
// Before
@Component({})
export class SearchComponent implements OnInit {
private route = inject(ActivatedRoute);
id$ = this.route.params.pipe(map(params) => params['id']);
ngOnInit() {
this.id$.subscribe(id => { // do something with the id });
}
}
// After
@Component({})
export class SearchComponent implements OnInit {
@Input() id?: string; // this will come from the path params
ngOnInit() {
// do something with the id
}
}
How to test it
To verify the new functionality, the RouterTestingHarness can be employed, taking care of the navigation process automatically.
Below is a sample illustrating how to test route information mapped to component inputs via the RouterTestingHarness:
@Component({})
export class SearchComponent {
@Input() id?: string;
@Input() query?: string;
}
it('sets id and query inputs from matching query params and path params', async () => {
TestBed.configureTestingModule({
providers: [ provideRouter(
[{ path: 'search/:id', component: SearchComponent }],
withComponentInputBinding()
) ],
});
const harness = await RouterTestingHarness.create();
const instance = await harness.navigateByUrl(
'/search/123?query=Angular',
TestComponent
);
expect(instance.id).toEqual('123');
expect(instance.query).toEqual('Angular');
await harness.navigateByUrl('/search/2?query=IsCool!');
expect(instance.id).toEqual('2');
expect(instance.query).toEqual('IsCool!');
});
That's all there is to it!
Caveats
- At times, we need
idorqueryParamsas observables, enabling us to merge them with other streams and fetch data.
Take a scenario where a component relies on both id and queryParams to retrieve data from a backend:
@Component({})
export class SearchComponent implements OnInit {
private dataService = inject(DataService);
@Input() id?: string;
@Input() query?: string;
ngOnInit() {
this.dataService.getData(this.id, this.query).subscribe(data => {
// do something with the data
});
}
}
To leverage the async pipe for data subscription, the id and query values must be exposed as observables rather than plain strings—otherwise, the following snippet won't function as expected:
@Component({})
export class SearchComponent implements OnInit {
private dataService = inject(DataService);
@Input() id?: string;
@Input() query?: string;
// this will not work because the id and the query don't have a value yet (they are undefined)
// they will have a value only after the component is initialized and the inputs are set
data$ = this.dataService.getData(this.id, this.query);
}
To turn id and query into observables, the BehaviorSubject comes into play:
@Component({
template: `
<div *ngIf="data$ | async as data">
{{ data }}
</div>
`
})
export class SearchComponent implements OnInit {
private dataService = inject(DataService);
id$ = new BehaviorSubject<string | null>(null);
query$ = new BehaviorSubject<string | null>(null);
@Input() set id(id: string) { this.id$.next(id); }
@Input() set query(query: string) { this.query$.next(query); }
data$ = combineLatest([
this.id$.pipe(filter(id => id !== null)),
this.query$.pipe(filter(query => query !== null))
]).pipe(
switchMap(([id, query]) => this.dataService.getData(id, query))
);
}
Here, the BehaviorSubject is what powers the id and query, while the combineLatest operator merges them into a single stream, and switchMap pulls the actual server data
In my view, that's overengineered for such a simple scenario, so the ActivatedRoute service is a better fit than this new API here.
- What takes precedence when route data holds clashing names. For instance, imagine a route set up like this:
const routes: Routes = [
{
path: 'test/:value',
component: TestComponent,
data: { value: 'Hello from data' },
}
];
@Component({ template: `{{ value }}` })
export class TestComponent {
@Input() value?: string;
}
With the new API, route data gets mapped to component inputs based on this precedence:
- Resolved data
- Path parameters
- Query parameters
The lookup falls back step by step: absence of resolved data triggers path parameters, which then gives way to query parameters when missing. Should both be unavailable, the input simply ends up as undefined!
- This makes the input's origin ambiguous 😬
My take on handling this? Rename the imported Input and alias it accordingly:
import { Input as RouteInput, Component } from "@angular/core";
@Component({ template: `{{ value }}` })
export class TestComponent {
@RouteInput() value?: string;
}
// OR
import { Input as QueryParamInput, Component } from "@angular/core";
@Component({ template: `{{ value }}` })
export class TestComponent {
@QueryParamInput() value?: string;
}
While this approach works, it's clear that the input is not standard—it's tied directly to the router's data.
Conclusion
I trust you found this piece enjoyable and that this capability proves valuable in your work.
Got a question or a suggestion? Drop it in the comments section below.
Try out the functionality right now: https://stackblitz.com/edit/angular-jb85mb?file=src/main.ts 🎮
Appreciate you reading!
My feed is packed with Angular content—breaking news, video tutorials, podcast episodes, feature updates, RFC discussions, pull request highlights, and plenty more. If that sounds interesting, you can follow me on @Enea_Jahollari. And if this article was to your liking, follow me on dev.to for more posts in a similar vein!
