Leveraging Dependency Injection to reduce duplicated code in Angular
How to eliminate duplicated code when getting route params, query params or data from Activated Route in Angular by using dependency injection
How to eliminate duplicated code when getting route params, query params or data from Activated Route in Angular by using dependency injection.
The duplication pattern
In some projects that I recently worked on, there's a block of code that is used in many places. The code 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$
}
}
The above code gets an observable of id
from route param map via ActivatedRoute
.
In other places, it would be getting the customerId
, or getting the currentTabId
or getting data from ActivatedRoute
or ActivatedRouteSnapshot
and do something with it.
The pattern is that using the ActivatedRoute
service to get value from the paramMap
, queryParamMap
, or the data
- either as an observable or as a snapshot.
In fact, there's nothing wrong with the above code block. However, when it comes to writing unit test, you need to mock the implementation of ActivatedRoute
to test the component.
The mock version of ActivatedRoute
would somehow look like the following:
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));
}
}
Then, the test of MyComponent
would be like this
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();
});
});
});
If your component gets data from queryParamMap
in ActivatedRoute
, you should also mock the implementation of queryParamMap
just like we did with paramMap
.
In fact, we can reduce this duplicated logic by using dependency injection. Here're 3 steps to do that.
Declare factory functions to get value from ActivatedRoute
First, we create a file named activated-route.factories.ts
, and write factory functions to get value from ActivatedRoute
. We write this code only once, then reuse it in other places.
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);
};
}
In order to get data from ActivatedRoute
service, the logic of factory functions would be the same.
Declare injection token and provider for this token in your component
Next, you need to define a dependency injection token in your component, and provide value for it. Here's how to do that.
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 {}
In the providers list of your component, you provide value for APP_SOME_ID
by calling the factory function routeParamFactory('id')
. The static param key string 'id'
is actually matched with the one that you declare in your routes configuration. For example
const routes: Routes = [
{
path: ':id',
component: MyComponent
}
];
Inject the token in component's constructor and use it
The next step is to inject the token you declared in previous step in the component's constructor and use it.
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$
}
So now, when you write unit test for MyComponent
, it would be simple like this
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();
});
});
});
You don't need to mock the whole ActivatedRoute
service. Instead, you just provide mock value of id
observable and that's it.
There are some benefits of this approach
- It helps reducing duplicated logic in your code so your code would look cleaner, easier to understand and to maintain.
- It's easier for you to test your component. The actual thing you need is the banana, not the whole jungle and a gorilla holding a banana.
Conclusion
Dependency Injection in Angular is a powerful tool that in my opinion, you should leverage it as much as possible. In this article, I walked you through the pattern of duplication when getting route parameters from ActivatedRoute
service. Then I showed you how to reduce duplicated code by using dependency injection in just 3 simple steps.
The full code can be found on Github in case you'd like to explore it further.
Thank you for reading and have a great day!