DEV Community

Cover image for Advanced Usage of Angular Dependency Injection
Evgeniy OZ
Evgeniy OZ

Posted on • Originally published at Medium

Advanced Usage of Angular Dependency Injection

inject()

A long time ago in a galaxy far, far away, we were declaring dependencies of our Angular components in the constructor:

    @Component({
      selector: 'app-example',
      template: ``
    })
    export class ExampleComponent {
      constructor(private readonly authService: AuthenticationService) {
      }
    }
Enter fullscreen mode Exit fullscreen mode

And our services were declaring their dependencies in the constructor:

    @Injectable({ providedIn: "root" })
    class AuthenticationService {
      constructor(private readonly http: HttpClient) {
      }

      getCurrentUserProfile(): Observable<UserProfile> {
        return this.http.get<UserProfile>('/user/profile');
      }
    }
Enter fullscreen mode Exit fullscreen mode

But then magical inject() was brought to our world, and we now declare our dependencies this way:

    @Component({
      selector: 'app-example',
      template: ``
    })
    export class ExampleComponent {
      private readonly authService = inject(AuthenticationService);
    }

    @Injectable({ providedIn: "root" })
    class AuthenticationService {
      private readonly http = inject(HttpClient);

      getCurrentUserProfile(): Observable<UserProfile> {
        return this.http.get<UserProfile>('/user/profile');
      }
    }
Enter fullscreen mode Exit fullscreen mode

It allows us to get rid of the constructor() and to avoid situations, when we are trying to use fields before they are declared (fields, declared in the constructor(), will be initialized after the fields, declared in the class body). If we try to use a field that is not initialized yet, TypeScript will raise an error:

    export class ExampleComponent {
      private readonly profile$ = this.authService.getCurrentUserProfile();
      // ‼️ TS2729: Property authService is used before its initialization.
      private readonly authService = inject(AuthenticationService);
    }
Enter fullscreen mode Exit fullscreen mode

And if we do this in the constructor(), TypeScript will let us compile the code, and we’ll find an error only in runtime:

    export class ExampleComponent {
      private readonly profile$ = this.authService.getCurrentUserProfile();

      constructor(private readonly authService: AuthenticationService) {
        // no errors from TypeScript, although authService is used 
        // before initialization.
      }
    }
Enter fullscreen mode Exit fullscreen mode

Angular DI can only inject dependencies, decorated with the @Injectable() decorator.

In that decorator, you can define if you want a “singleton” or not, using providedIn. Initially, it had multiple possible options, but they all deprecated, and there is just one choice you can make: add providedIn: "root" or not.

With providedIn: "root", the first instance of the class will not be destroyed when its injector is destroyed.

Let’s see how inject() works step by step when you use it:

  1. Check if the requested dependency is provided in the current scope (in the “providers” array);

  2. Recursively check parent components (parent injectors);

  3. If the current component is inside a module — check the module’s “providers”;

  4. Check the “root” providers.

  5. Throw an exception if the provider can not be found.

There is the second argument, where you can set flags “self”, “skipSelf”, “optional” and “host” — they will modify that flow if you set them.

Because of this strategy, we can put our services to the root level providers array, and an injector will always find them there. That’s why AppModule or bootstrapApplication has that large list of providers.

It is very convenient, but you should not forget one thing: singletons (providedIn: “root”) share a single instance with all the consumers. And every internal variable of your service can be modified by multiple components.

In the example below, our service has a public field lastError:

    @Injectable({ providedIn: "root" })
    class ProductsService {
      public lastError?: string;

      private readonly http = inject(HttpClient);

      getProducts(): Observable<Product[]> {
        return this.http.get<Product[]>('/products');
      }
    }
Enter fullscreen mode Exit fullscreen mode

And we have two components with similar methods:

    @Component({
      selector: 'app-products-one',
      template: `
        <div>error: {{productsSrv.lastError}}</div>
        <ul>
          <li *ngFor="let product of getProducts()|async">
            {{product.label}}: {{product.price}}
          </li>
        </ul>
      `
    })
    export class ProductsComponent1 {
      protected readonly productsSrv = inject(ProductsService);

      getProducts() {
        return this.productsSrv.getProducts().pipe(
          catchError((err) => {
            this.productsSrv.lastError = err;
            return EMPTY;
          })
        );
      }
    }
Enter fullscreen mode Exit fullscreen mode

If one of them catches some error, it will update that public field of ProductsService, and both components will display an error message, not just one of them.

It is a very simplified example — usually errors like this are more sophisticated. I’ve seen quite interesting and quite devastating bugs, caused by misunderstanding the fact, that all consumers of global services share the same instance. It doesn’t mean we should not use them, but they should either have no shared state or provide safe access to the shared state (services that provide it, called “stores”).

We can modify the strategy of inject() a little, using the second argument, that accepts InjectOptions:

    interface InjectOptions {
      // Use optional injection, and return null if the requested token 
      // is not found.
      optional?: boolean;
      // Start injection at the parent of the current injector.
      skipSelf?: boolean;
      // Only query the current injector for the token, and don't fall back 
      // to the parent injector if it's not found.
      self?: boolean;
      // Stop injection at the host component's injector.
      host?: boolean;
    }
Enter fullscreen mode Exit fullscreen mode

Code Reuse

One obvious usage of DI is code reuse — you define some scope of responsibility, create a class that implements it, and now you can reuse that code by injecting that service.

Components are difficult to reuse because they have templates. Because of that, all the code that doesn’t contain knowledge about the template details is better to move to a *local store * — a special service that will be instantiated together with a component, and destroyed when the component is destroyed.

This service will not be shared with other components (and with other instances of the same component), so we can safely encapsulate the state of our component in that store.

If we take a look at the steps of inject() again (see above), we’ll find out that to get a local instance of our store, we need to:

  1. Add decorator @Injectable() to the class of our store;

  2. Do not add providedIn: "root"

  3. Add the name of our store to the list of providers of our component.

💡 You can find code examples and more information about different kinds of stores in this article.

Next time when you need a component “almost like that one”, but with a different template, you can use the power of OOP and either extend the local store of that component or inject it and use it as a delegate.

First, let’s move the code from our component to the store:

    @Component({
      selector: 'app-all-products',
      template: `
        <div>All Products</div>
        <ul>
          <li *ngFor="let product of store.getProducts()|async">
            {{product.label}}: {{product.price}}
          </li>
        </ul>
      `,
      providers: [
        ProductsStore
      ]
    })
    export class AllProductsComponent {
      protected readonly store = inject(ProductsStore);
    }

    // Store

    @Injectable()
    export class ProductsStore {
      protected readonly productsSrv = inject(ProductsService);

      getProducts() {
        return this.productsSrv.getProducts().pipe(
          catchError((err) => {
            this.productsSrv.lastError = err;
            return EMPTY;
          })
        );
      }
    }
Enter fullscreen mode Exit fullscreen mode

Now, we can reuse it in another component-store pair:

    @Injectable()
    export class FavoriteProductsStore extends ProductsStore {
      override getProducts(): Observable<Product[]> {
        return super.getProducts().pipe(
          map((products) => products.filter(p => p.isFavorite))
        );
      }
    }

    @Component({
      selector: 'app-fav-products',
      template: `
        <div>Favorite Products</div>
        <ul>
          <li *ngFor="let product of store.getProducts()|async">
            {{product.label}}: {{product.price}}
          </li>
        </ul>
      `,
      providers: [
        FavoriteProductsStore
      ]
    })
    export class FavoriteProductsComponent {
      protected readonly store = inject(FavoriteProductsStore);
    }
Enter fullscreen mode Exit fullscreen mode

Communication

Services are easy to extend and compose, that’s why it’s better to move as much code as possible to a store. But there is one more reason: you can inject a parent’s local store.

If inject() can not find the needed service in the providers array of the current injector (current component), it will check its parent. And then the parent of that parent, recursively.

So if you are absolutely sure that some component will be always a child of some other component, you can use this to communicate with the parent (or other ancestor).

It is especially useful when you need to either pass a bunch of inputs to the deeply nested child or bubble some outputs to some ancestor. Without DI and stores, you would need to pass inputs on every level (often adding them to the components with the sole purpose of passing this data deeper), our add outputs the same way.

While more tiresome, passing inputs/outputs through gives more reusable components. Components, that inject local stores of their ancestors, are tightly coupled by this dependency — they can not be reused if there are no needed stores in their ancestors' line. Only use it when you are absolutely sure that needed parents will be there.

There is one special case when you can be sure that the needed store will be there: feature stores. The most important responsibility of feature stores is to manage the feature’s state.

For example, you have a large part of your app called “Feedback”, where you let users leave some feedback, and where your team can manage it.

Many components of the Feedback feature will only be reused inside that part of your app, so it is safe to have this dependency on FeedbackStore for them. They can use it to have access to the shared collection of feedback messages, modify it, and reactively render all the modifications.


🪽 Do you like this article? Share it and let it fly! 🛸

💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.

Top comments (0)