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

·

5 min read

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!