651.288.7000 info@intertech.com

Note: This post is part of an eleven-part series on Angular development. You can find the series overview here.

Overview

Angular’s development team strongly encourages following the single responsibility principle when creating components and classes. When components grow too large we should turn to services to help separate complex functionality to improve readability, complexity, and testability of an application. However, there are times when the singleton nature of services can provide unexpected results. This article focuses on the ability to control the scope of Angular providers to provide the desired functionality within an application.

To follow along with the articles there a two repositories created: a starting point and final solution.

Setup

To demonstrate the situation, we’ll create a sample component that relies upon a service for some of it’s functionality.  This component is a simple card with a button to request a new number and a loading flag to show a spinner while the number is being fetched.  The service generates a random number within a timeout so we have a chance to see the spinner during execution.  Let’s start with the standard approach of creating a root level service to inject:

@Injectable({
   providedIn: 'root'
})
export class LoadingCardService {
   public isLoading = false;
   public randomValueSubject = new BehaviorSubject<number>(null);

   public randomValue(): Observable<number> {
      return this.randomValueSubject.asObservable();
   }

   public runSomething(): void {
      this.isLoading = true;

      setTimeout(() => {
         const randomNumber = Math.floor(Math.random() * 100);
         this.randomValueSubject.next(randomNumber);
         this.isLoading = false;
      }, 2000);
   }
}
And we can then create a component to display the number and a spinner while loading:
loading-card.component.html
<div class="card">
    <div class="card-header">
        <h1>{{type}} Card</h1>
    </div>
    <div class="card-body">
        Your random number is: <span id="random-val">{{loadingCardService.randomValue() | async}}</span>
    </div>
    <div class="card-footer">
        <button id="load-button" class="btn btn-primary" (click)="loadingCardService.runSomething()" [disabled]="loadingCardService.isLoading">
            <span class="spinner-border spinner-border-sm" *ngIf="loadingCardService.isLoading"></span>
            Click Me!
        </button>
    </div>
</div>
loading-card.component.ts
@Component({
    selector: 'app-loading-card-global',
    templateUrl: './loading-card.component.html'
})
export class LoadingCardGlobalComponent {
    public type = 'Global';
    constructor(public loadingCardService: LoadingCardService) { }
}
When we create this card, we can see the following functionality:

Dynamic Component Generation

In order to demonstrate the side effects with this approach, we will employ another advanced technique – Dynamic Component Generation.  If you are not familiar with the technique – see the linked article that discusses how Angular can be used to create and add a component into the DOM dynamically.  Below is the wrapper component we are using for this demonstration without commentary on the approach.  Refer to the linked article for more detail and background on this technique.  For the sake of this article, this component is the parent component being used that allows for the generation of multiple instances of our test component.

The situation demonstrated in the article is not limited to dynamically generated components. This would just as easily occur by manually including multiple instances anywhere in the application, but dynamic generation is a simpler method to interact with multiple variations of the test component.

@Component({
   selector: 'app-component-scope',
   template: `
        <button class="btn btn-info" (click)="addGlobalCard()">Add Global Card</button>
        <div class="card-group pt-5">
            <ng-template #content></ng-template>
        </div>`
})
export class ComponentScopeComponent {
   @ViewChild('content', { read: ViewContainerRef }) content: ViewContainerRef;

   constructor(private componentFactoryResolver: ComponentFactoryResolver) { }

   addGlobalCard(): void {

      const factory = this.componentFactoryResolver.resolveComponentFactory(type);
      this.content.createComponent(factory);
   }
}

Testing Multiple Instances

Now with the ability to create multiple instances of the component, let’s see what happens:

 When clicking the button to generate a value, both cards showed a spinner and the same resulting random number.  This happens because all services are singletons in Angular when injected at the root level.  When an object is created that requests a provider through the constructor, Angular will pass the same instance to all objects that request the provider.  Normally, this is what we want and allows for a single location to store and share data throughout an application and ultimately manage the state of the application.  This default approach is exactly what is expected for something like an authentication service, we wouldn’t want multiple instances that have different users or authentication states. But in this case, the default approach is misleading and could lead to unexpected behavior.

Changing Provider Scope

To address this, Angular gives us the ability to control the scope of a provider.  The great part about this is that it does not require any changes to the service.  As you will see, we can use the same service provided globally, but then use a different version in a subsection of the application.  We can create a new component that uses the same template as the initial test component:

@Component({
    selector: 'app-loading-card',
    templateUrl: './loading-card.component.html',
    providers: [LoadingCardService]
})
export class LoadingCardLocalComponent {
    public type = 'Local';
    constructor(public loadingCardService: LoadingCardService) { }
}

The main difference is the component declares a provider in the decorators. When Angular’s dependency injection looks for an instance of a provider to inject, it does so starting from the component and working its way up the component tree all the way to the root of the application. This is why services are generally given the “providedIn: ‘root’” decorator – it is shorthand to tell Angular to provide it in the root application module. What is important to note here is that we are not limited to defining the provider just at the consuming component. This allows us to set the scope of a service to a module or a parent component and gives a great deal of flexibility to easily control what is injected where.

To test this, I’ll add the option to create cards of both types in our home component:

@Component({
   selector: 'app-component-scope',
   template: `
      <button class="btn btn-info" (click)="addGlobalCard()">Add Global Card</button>
      <button class="btn btn-info" (click)="addLocalCard()">Add Local Card</button>
      <div class="card-group pt-5">
         <ng-template #content></ng-template>
      </div>`
})
export class ComponentScopeComponent {
   @ViewChild('content', { read: ViewContainerRef }) content: ViewContainerRef;

   constructor(private componentFactoryResolver: ComponentFactoryResolver) { }

   addGlobalCard(): void {
      this.addCard(LoadingCardGlobalComponent);
   }

   addLocalCard(): void {
      this.addCard(LoadingCardLocalComponent);
   }

   addCard(type: Type<unknown>): void {
      const factory = this.componentFactoryResolver.resolveComponentFactory(type);
      this.content.createComponent(factory);
   }
}
Let’s create two components of each type and see it in action:

Now, when clicking a single load button on a globally scoped service component we can see all the global components react and start loading as before and also load the same random value from the service as before – however the components with a locally scoped service remain in the initial state. when clicking the load button on one of those services it will load separately and create a different random number.  The other locally scoped component still remains in the initial state. 

You can see how with a single change we can control how data is shared and controlled in the application.  Our global cards are bound together and work off the same set of data, the local cards have their own service instance and don’t share data with each other. 

Child Component Dependency Injection

But what happens with child components?  Let’s pull the button out into it’s own component and find out:

@Component({
    selector: 'app-loading-button',
    template: `
    <button id="load-button" class="btn btn-primary" (click)="loadingCardService.runSomething()" [disabled]="loadingCardService.isLoading">
        <span class="spinner-border spinner-border-sm" *ngIf="loadingCardService.isLoading"></span>
        Click Me!
    </button>`
})
export class LoadingButtonComponent {
    constructor(public loadingCardService: LoadingCardService) { }
}
And our test component’s template changes:
SNIP...
<div class="card-footer">
    <app-loading-button></app-loading-button>
</div>
SNIP...

And finally we can test again and see we get the exact same behavior:

The buttons are still bound to the isLoading value for the instance of the provider that comes from the parent component.  Remember, Angular walks up the component tree to find the lowest level provider to inject so the button will find the service scope at the global or local level depending on the setup of the parent. This means you can alter the provider at any level of an application – a module, a route component, down to the smallest display component and alter the behavior for any children.

Testing Notes

There is an important point to take notice of from a testing perspective.  The rule of how Angular selects the instance to inject works the same in the TestBed.  Therefore, you can no longer just declare a provider in the configureTestingModule method – this is the same as declaring a service at the root level.  When the TestBed creates the test component, the component level declaration will take over and the actual service class will be injected, not the mock defined in the test.  Let’s create a test and see this in action – below is the test we will use for both types of components and it will simply mock the randomValue() method to return 5 and check the template for the value:

describe('LoadingCardGlobalComponent', () => {
    let component: LoadingCardGlobalComponent;
    let fixture: ComponentFixture<LoadingCardGlobalComponent>;
    let loadingCardService: jasmine.SpyObj<LoadingCardService>;

    beforeEach(() => {
        loadingCardService = jasmine.createSpyObj(['runSomething', 'randomValue']);
        loadingCardService.randomValue.and.returnValue(of(5));

        TestBed.configureTestingModule({
            declarations: [LoadingCardGlobalComponent, LoadingButtonComponent],
            providers: [
                { provide: LoadingCardService, useValue: loadingCardService }
            ],
            schemas: [NO_ERRORS_SCHEMA]
        }).compileComponents();
        fixture = TestBed.createComponent(LoadingCardGlobalComponent);
        component = fixture.componentInstance;

        fixture.detectChanges();
    });

    it('should create the component', () => {
        expect(component).toBeTruthy();
    });

    it('should show the random number', () => {
        const valElement = fixture.debugElement.query(By.css('#random-val')).nativeElement;
        expect(valElement.innerText).toContain(5);
    });
});
When these are executed, we get a failure saying that the random number span is empty and not 5.

We don’t see an injection error. We just aren’t seeing the mock value because an actual instance of the service is being used.  The creation of the component is lower in the tree than the TestBed module, so that takes precedence.  What needs to be done is to override the component level definition:

SNIP...

beforeEach(() => {

   loadingCardService = jasmine.createSpyObj(['runSomething', 'randomValue']);
   loadingCardService.randomValue.and.returnValue(of(5));

   TestBed.configureTestingModule({
      declarations: [LoadingCardLocalComponent],
      providers: [
         { provide: LoadingCardService, useValue: loadingCardService }
      ],
      schemas: [NO_ERRORS_SCHEMA]
   });

   TestBed.overrideComponent(LoadingCardLocalComponent, {
      set: {
         providers: [
            { provide: LoadingCardService, useValue: loadingCardService }
         ]
      }
   });

   TestBed.compileComponents();
   fixture = TestBed.createComponent(LoadingCardLocalComponent);
   component = fixture.componentInstance;

   fixture.detectChanges();
});

SNIP...
The overrideComponent method let’s us change the decorator on the component.  Just note that this has to be done prior to calling compileComponents() or createComponent()!

Now the test is using the mock service we as we expect it to.

Conclusion

It is important to note what has been demonstrated works for any provider, not just services.  Take a moment to consider the many possibilities this can open up for creating re-usable and truly dynamic components in Angular.  Pulling default values or configuration options into injected providers gives users much more control over components and how they function.  There are many options that generally get added as Input properties that can make far more sense as providers – which can also help to greatly clean up templates and overall application organization by allowing you to build components that far better follow SOLID design.  It also provides a way to give a simple to implement, high-level way to control data sharing within the application.

About Intertech

Founded in 1991, Intertech delivers software development consulting to Fortune 500, Government, and Leading Technology institutions, along with real-world based corporate education services. Whether you are a company looking to partner with a team of technology leaders who provide solutions, mentor staff and add true business value, or a developer interested in working for a company that invests in its employees, we’d like to meet you. Learn more about us.