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,
  },
];
Enter fullscreen mode Exit fullscreen mode

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  },
];
Enter fullscreen mode Exit fullscreen mode
@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,
  },
];
Enter fullscreen mode Exit fullscreen mode
@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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  },
];
Enter fullscreen mode Exit fullscreen mode
@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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  },
];
Enter fullscreen mode Exit fullscreen mode
@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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Strip out the ActivatedRoute from the component’s constructor.
  2. Decorate the properties tied to route data with the @Input() decorator.
  3. Turn on the bindToComponentInputs option in either RouterModule or the provideRouter function.

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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; 
}
Enter fullscreen mode Exit fullscreen mode
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!');
});
Enter fullscreen mode Exit fullscreen mode

That's all there is to it!

Caveats

  • At times, we need id or queryParams as 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
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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); 
}
Enter fullscreen mode Exit fullscreen mode

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))
    );
}
Enter fullscreen mode Exit fullscreen mode

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' },
  }
];
Enter fullscreen mode Exit fullscreen mode
@Component({ template: `{{ value }}` })
export class TestComponent {
  @Input() value?: string;
}
Enter fullscreen mode Exit fullscreen mode

With the new API, route data gets mapped to component inputs based on this precedence:

  1. Resolved data
  2. Path parameters
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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!