Leveraging Dependency Injection to reduce duplicated code in Angular

Post Editor

How to eliminate duplicated code when getting route params, query params or data from Activated Route in Angular by using dependency injection.

4 min read
4 comments
post

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.

post
post
4 min read
4 comments
4 comments

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
Link to this section

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

<>Copy
@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:

<>Copy
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

<>Copy
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
Link to this section

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.

<>Copy
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
Link to this section

Next, you need to define a dependency injection token in your component, and provide value for it. Here's how to do that.

<>Copy
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

<>Copy
const routes: Routes = [ { path: ':id', component: MyComponent } ];

Inject the token in component's constructor and use it
Link to this section

The next step is to inject the token you declared in previous step in the component's constructor and use it.

<>Copy
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

<>Copy
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
Link to this section

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!

Comments (4)

authorBharathRavi27
3 July 2021

Thanks for putting it all together. It was indded a good read!

authorabductedMind
4 July 2021

You make a good point on factories simplifying testing. BUT...why not use @Injectable to create a single service that holds all these pure function factories rather than injecting providers as factories? Having to manage injection tokens like you suggest implies a possible need to have multiple instances of these factories - the use doesn't align well with the stated premiss of the article.

authorphhien203
5 July 2021

Thanks for your feedback, could you please share your code snippets to illustrate your idea?

authoralexkunin
5 July 2021

Nice article, thank you for sharing!

I think by using some inheritance we can reduce amount of boilerplate even more. E.g. this could be generic part (it could support more configuration options, like all you do you your factory functions, but this is just an example):

@Injectable({
  providedIn: 'root',
})
export abstract class RouteObservable extends Observable<string> {
  protected abstract bag: 'params' | 'queryParams';
  protected abstract property: string;

  constructor(route: ActivatedRoute) {
    super(subscriber => {
      const subscription = route[this.bag].pipe(pluck(this.property)).subscribe(subscriber);
      return () => {
        subscription.unsubscribe();
      };
    });
  }
}

And here is specific value extractor:

@Injectable({providedIn: 'root'})
export class IdQueryParam extends RouteObservable {
  protected bag = 'queryParams' as const;
  protected property = 'id';
}

Finally, this is how you use it in your component:

constructor(id$: IdQueryParam) {
  id$.pipe(takeUntil(this.destroyed$)).subscribe(console.log);
}

So, the most repetitive part is also the shortest (basically moving providers from @Component() to global DI space, and also reusing ES class constructor as DI token).

And in your tests it could be as simple as this:

providers: [
  {
    provide: IdQueryParam,
    useValue: scheduled(of('1234'), asyncScheduler),
  },
],
authorkonrad-szk
6 July 2021

It doesn't work because ActivatedRoute depends on component. In my POC, params were always empty.

Share

About the author

author_image

Front-end developer at Inspectorio, Angular enthusiast

author_image

About the author

Hien Pham

Front-end developer at Inspectorio, Angular enthusiast

About the author

author_image

Front-end developer at Inspectorio, Angular enthusiast

NxAngularCli
NxAngularCli
NxAngularCli

Featured articles