All Blogs

Abstract inject() function the right way

Since Angular 14, we have had the almighty inject() function as a way to inject dependencies into our Angular entities.

We won’t be discussing the differences between inject() and traditional Constructor DI in this blog post.

Custom Inject Functions

Some folks are going to hate me for this πŸ˜…, but it’s ok. I personally like inject() because it allows for better compositions with Custom Inject Functions (if you are familiar with React, then this is somewhat similar to Custom Hooks). However, there is one caveat to inject() that it has to be invoked in an Injection Context.

The snippets in this blog post will be using some of Angular Three so it makes sense for some of the points I am going to make.

It is easy to spot misuses of inject() when we use it directly like follow:

1
import { DestroyRef } from "@angular/core";
2
import { NgtStore } from "angular-three";
3
4
export class Model {
5
// πŸ‘‡ correct usage βœ…
6
private store = inject(NgtStore);
7
private destroyRef = inject(DestroyRef);
8
private beforeRenderCleanup = this.store.get("internal").subscribe(() => {
9
/* code to be ran in an animation loop */
10
});
11
private _nonUse_ = this.destroyRef.onDestroy(() => {
12
this.beforeRenderCleanup();
13
});
14
15
constructor() {
16
// If we do not need any of the above anywhere else, then constructor is a great spot
17
// πŸ‘‡ correct usage βœ…
18
const beforeRenderCleanup = inject(NgtStore)
19
.get("internal")
20
.subscribe(() => {
21
/* code to be ran in an animation loop */
22
});
23
inject(DestroyRef).onDestroy(() => {
24
beforeRenderCleanup();
25
});
26
}
27
28
ngOnInit() {
29
// πŸ‘‡ going to throw error ❌
30
// πŸ‘‡ because ngOnInit isn't an Injection Context
31
const beforeRenderCleanup = inject(NgtStore)
32
.get("internal")
33
.subscribe(() => {
34
/* code to be ran in an animation loop */
35
});
36
inject(DestroyRef).onDestroy(() => {
37
beforeRenderCleanup();
38
});
39
}
40
}

On the other hand, errors relating to Injection Context are harder to spot (and debug) when we have Custom Inject Functions (CIFs). Let’s assume that we want to provide an easier way for consumers to run some code in the animation loop, we will probably need to create a CIF

1
/* extra typings in this snippet are irrelevant */
2
import { NgtStore } from "angular-three";
3
4
export function injectBeforeRender(
5
cb: NgtBeforeRenderRecord["callback"],
6
priority = 0,
7
) {
8
const store = inject(NgtStore);
9
const cleanup = store.get("internal").subscribe(cb, priority, store);
10
inject(DestroyRef).onDestroy(() => void cleanup());
11
return cleanup;
12
}

Then, our component can be updated as follow 🎊!

1
export class Model {
2
constructor() {
3
injectBeforeRender(() => {
4
/* code to be ran in an animation loop */
5
});
6
}
7
}

The Limitations

This looks clean! But, injectBeforeRender comes with some limitations. Let’s take a look at the following scenario

1
export class Model {
2
// Model now accepts an Input for renderPriority to customize the order of the code that runs in the animation loop
3
@Input() renderPriority = 0;
4
}

Limitation 1: Input values aren’t resolved in Injection Context, yet

We now have to pass renderPriority in injectBeforeRender as the second argument. Of course, we can invoke injectBeforeRender in constructor but by the time the constructor is invoked, Angular hasn’t resolved the Input value yet.

1
export class Model {
2
@Input() renderPriority = 0;
3
4
constructor() {
5
// This won't work because `renderPriority` is always 0
6
injectBeforeRender(() => {
7
/* code to be ran in an animation loop */
8
}, this.renderPriority);
9
}
10
}

Limitation 2: Outside of Injection Context

We know that ngOnInit is one of the places where Angular has resolved the Input value but ngOnInit is invoked outside of an Injection Context.

1
export class Model {
2
@Input() renderPriority = 0;
3
4
ngOnInit() {
5
// This won't work because `injectBeforeRender` is invoked outside of an Injection Context
6
injectBeforeRender(() => {
7
/* code to be ran in an animation loop */
8
}, this.renderPriority);
9
}
10
}

One extra caveat for the 2nd limitation is when injectBeforeRender throws, we will see a generic message relating to inject() being invoked outside of an Injection Context. Nothing points to injectBeforeRender being the one function that throws.

We cannot fix the limitations but we can at least workaround them by making our CIF more robust and more responsible. Yes, for the extra caveat as well.

The better way of making a CIF

First, let’s work on the extra caveat. This one is easy because we can use a utility provided by Angular assertInInjectionContext()

1
/* extra typings in this snippet are irrelevant */
2
import { NgtStore } from "angular-three";
3
import { assertInInjectionContext } from "@angular/core";
4
5
export function injectBeforeRender(
6
cb: NgtBeforeRenderRecord["callback"],
7
priority = 0,
8
) {
9
assertInInjectionContext(injectBeforeRender);
10
const store = inject(NgtStore);
11
const cleanup = store.get("internal").subscribe(cb, priority, store);
12
inject(DestroyRef).onDestroy(() => void cleanup());
13
return cleanup;
14
}

And with that, the extra caveat is taken care of. When injectBeforeRender throws (in dev mode), we will see an error stating that injectBeforeRender being invoked outside of an Injection Context.

To work around the limitations, we need to allow our CIF to accept an optional parameter of type Injector. An Injector represents the Injection Context that provides that Injector. With the Injector argument, the consumers can control the Injection Context that a CIF is invoked. We want it to be optional because most of the times, it should not be needed.

1
/* extra typings in this snippet are irrelevant */
2
import { NgtStore } from "angular-three";
3
import { assertInInjectionContext } from "@angular/core";
4
5
export function injectBeforeRender(
6
cb: NgtBeforeRenderRecord["callback"],
7
priority = 0,
8
{ priority = 0, injector }: { priority?: number; injector?: Injector } = {},
9
) {
10
assertInInjectionContext(injectBeforeRender);
11
const store = inject(NgtStore);
12
const cleanup = store.get("internal").subscribe(cb, priority, store);
13
inject(DestroyRef).onDestroy(() => void cleanup());
14
return cleanup;
15
}

Half way there! Our CIF now has injector argument but it has to decide whether to use that custom injector or use the default injector (i.e: the current Injection Context that the CIF is invoked in). To achieve this, we will create a function that will guarantee anything below it is running in an Injection Context

1
export function assertInjector(fn: Function, injector?: Injector): Injector {
2
// we only call assertInInjectionContext if there is no custom injector
3
!injector && assertInInjectionContext(fn);
4
// we return the custom injector OR try get the default Injector
5
return injector ?? inject(Injector);
6
}

With this, we can update our CIF as follow

1
/* extra typings in this snippet are irrelevant */
2
import { NgtStore } from "angular-three";
3
import { assertInjector } from './assert-injector';
4
5
export function injectBeforeRender(
6
cb: NgtBeforeRenderRecord["callback"],
7
{ priority = 0, injector }: { priority?: number; injector?: Injector } = {},
8
) {
9
assertInInjectionContext(injectBeforeRender);
10
injector = assertInjector(injectBeforeRender, injector);
11
// πŸ‘† injector is guaranteed to be an Injector instance whether it is custom or default
12
return runInInjectionContext(injector, () => {
13
const store = inject(NgtStore);
14
const cleanup = store.get("internal").subscribe(cb, priority, store);
15
inject(DestroyRef).onDestroy(() => void cleanup());
16
return cleanup;
17
})
18
}

Why do we use runInInjectionContext?

As its name suggests, runInInjectionContext runs arbitrary code in a provided Injector Context (i.e: an Injector). Instead of runInInjectionContext, we can also use injector.get() to retrieve the dependencies that our CIF needs but injector.get() seems like Service Locator which is seen as an anti-pattern by many.

Additionally, refactoring code to use runInInjectionContext is easy because we can move our existing code inside of runInInjectionContext and everything goes back to working.

How do we consume our CIF now?

With the above changes, consumers can safely consume our CIF injectBeforeRender in many different ways

1
export class Model {
2
@Input() renderPriority = 0;
3
4
constructor() {
5
// βœ… no renderPriority, everything works as before
6
injectBeforeRender(() => {
7
/* code to be ran in an animation loop */
8
});
9
}
10
11
private injector = inject(Injector);
12
13
ngOnInit() {
14
// βœ… works with custom Injector, Input works as well
15
injectBeforeRender(
16
() => {
17
/* code to be ran in an animation loop */
18
},
19
{
20
priority: this.renderPriority,
21
injector: this.injector,
22
},
23
);
24
25
// βœ… throws a clear error that "injectBeforeRender" is invoked outside of an Injection Context
26
injectBeforeRender(
27
() => {
28
/* code to be ran in an animation loop */
29
},
30
{ priority: this.renderPriority },
31
);
32
}
33
}

Conclusion

With the help of assertInInjectionContext and runInInjectionContext, we’ve made our Custom Inject Function (CIF) more robust by allowing the consumers to control the Injection Context that the CIF is invoked in and more responsible by telling the consumers that our CIF is the one throwing error if it is invoked outside of an Injection Context.

I personally use this approach for all CIFs that angular-three has. We did not discuss Testing CIFs in this blog post but I’ll definitely write up a new one when I discover things to share in that regard. For now, have fun!

Thank you

Thanks Enea for reviewing!

Published on Wed Sep 06 2023


Angular