The duplication pattern
Across several projects I've been involved with recently, a particular snippet of code shows up repeatedly. It looks like this:
@Component({
selector: 'app-my-component'
})
export class MyComponent implements OnInit {
id$: Observable<string> = this.route.paramMap.pipe(
takeUntil(this.destroy$),
map(params => params.get('id'))
);
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
// do something with this.id$
}
}
What this does is grab an observable of id from the route's paramMap through the ActivatedRoute service.
Elsewhere, the same logic applies — whether it's retrieving a customerId, a currentTabId, or pulling data from either ActivatedRoute or ActivatedRouteSnapshot to work with it.
The underlying theme is consistent: leveraging ActivatedRoute to extract values from paramMap, queryParamMap, or data — whether as an observable stream or from a snapshot.
Honestly, there's nothing inherently wrong with that code block. The real friction shows up during unit testing, where you'd have to mock how ActivatedRoute behaves just to test your component.
A typical mock of ActivatedRoute might look like what's described here:
export class ActivatedRouteStub {
// Use a ReplaySubject to share previous values with subscribers
// and pump new values into the `paramMap` observable
private subject = new ReplaySubject<ParamMap>();
constructor(initialParams?: Params) {
this.setParamMap(initialParams);
}
/** The mock paramMap observable */
readonly paramMap = this.subject.asObservable();
/** Set the paramMap observable's next value */
setParamMap(params: Params = {}) {
this.subject.next(convertToParamMap(params));
}
}
After that, the test for MyComponent would take this form:
const activatedRouteStub = new ActivatedRouteStub();
describe('MyComponent', () => {
let fixture: ComponentFixture<MyComponent>;
let component: MyComponent;
beforeEach(async () => {
// mock the value of paramMap
activatedRoute.setParamMap({id: 1234});
await TestBed.configureTestingModule({
declarations: [MyComponent],
providers: [
{
provide: ActivatedRoute,
useValue: activatedRouteStub
}
]
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
});
it('should get :id from route param', (done) => {
fixture.detectChanges();
component.id$.subscribe(id => {
expect(id).toBe('1234');
done();
});
});
});
And if your component happens to read from queryParamMap in ActivatedRoute, you'll need to stub that too — just as we did for paramMap.
Luckily, we can trim away this repetitive work with the help of dependency injection. It breaks down into three straightforward steps.
Define factory functions to pull values from ActivatedRoute
To start, create a file called activated-route.factories.ts and put factory functions inside that fetch values from ActivatedRoute. Write it once, then call it from anywhere you need it.
import {ActivatedRoute} from '@angular/router';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
// this factory function will get value as an observable from route paramMap
// based on the param key you passed in
// if your current route is '/customers/:customerId' then you would call
// routeParamFactory('customerId')
export function routeParamFactory(
paramKey: string
): (route: ActivatedRoute) => Observable<string | null> {
return (route: ActivatedRoute): Observable<string | null> => {
return route.paramMap.pipe(map(param => param.get(paramKey)));
};
}
// this factory function will get value as a snapshot from route paramMap
// based on the param key you passed in
export function routeParamSnapshotFactory(
paramKey: string
): (route: ActivatedRoute) => string | null {
return (route: ActivatedRoute): string | null => {
return route.snapshot.paramMap.get(paramKey);
};
}
// same as above factory, but get value from query param
// if your current route is 'customers?from=USA
// then you would call queryParamFactory('from')
export function queryParamFactory(
paramKey: string
): (route: ActivatedRoute) => Observable<string | null> {
return (route: ActivatedRoute): Observable<string | null> => {
return route.queryParamMap.pipe(map(param => param.get(paramKey)));
};
}
// same as queryParamFactory, but get snapshot, instead of observable
export function queryParamSnapshotFactory(
paramKey: string
): (route: ActivatedRoute) => string | null {
return (route: ActivatedRoute): string | null => {
return route.snapshot.queryParamMap.get(paramKey);
};
}
For retrieving data from ActivatedRoute, the factory functions follow the same idea.
Set up an injection token and provider in your component
Next, you'll want to declare a dependency injection token right in your component and supply a value for it. Here's how that's done.
export const APP_SOME_ID = new InjectionToken<Observable<string>>(
'stream of id from route param',
);
@Component({
selector: 'app-my-component',
templateUrl: './my-component.template.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: APP_SOME_ID,
useFactory: routeParamFactory('id'),
deps: [ActivatedRoute]
}
]
})
export class MyComponent {}
Within the component's providers array, you assign a value to APP_SOME_ID by invoking the factory function routeParamFactory('id'). The string 'id' must line up with the parameter key you've set in your route configuration. For instance:
const routes: Routes = [
{
path: ':id',
component: MyComponent
}
];
Inject the token into the component's constructor and make use of it
Now, all that's left is to bring the token into your component's constructor and put it to work.
export const APP_SOME_ID = new InjectionToken<Observable<string>>(
'stream of id from route param',
);
@Component({
selector: 'app-my-component',
templateUrl: './my-component.template.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: APP_SOME_ID,
useFactory: routeParamFactory('id'),
deps: [ActivatedRoute],
},
],
})
export class MyComponent {
constructor(@Inject(APP_SOME_ID) private readonly id$: Observable<string>) {}
// then do something with this.id$
}
With this setup, unit tests for MyComponent become much simpler:
describe('MyComponent', () => {
let fixture: ComponentFixture<MyComponent>;
let component: MyComponent;
beforeEach(async () => {
TestBed.overrideComponent(MyComponent, {
set: {
providers: [{
provide: APP_SOME_ID,
useValue: scheduled(of('1234'), asyncScheduler)
}]
}
});
await TestBed.configureTestingModule({
declarations: [MyComponent]
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
});
it('should get :id from route param', (done) => {
fixture.detectChanges();
component.id$.subscribe(id => {
expect(id).toBe('1234');
done();
});
});
});
No more stubbing out the entire ActivatedRoute service. All you need is to supply a mock observable of id, and you're done.
This strategy brings several advantages:
- It cuts down on repeated logic, making the code neater, more readable, and easier to maintain.
- Testing becomes a breeze — you only care about the value itself, not the entire infrastructure wrapped around it.
Conclusion
Angular's dependency injection is a robust feature, and in my view, it deserves to be used generously. In this write-up, I walked through a recurring pattern where route parameters taken from ActivatedRoute lead to code duplication. Then I demonstrated how a few small steps with dependency injection can eliminate that redundancy.
The complete example is available on Github if you want to dig deeper.
Thanks for sticking with me, and enjoy the rest of your day!
