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:
1import { DestroyRef } from "@angular/core";2import { NgtStore } from "angular-three";3
4export 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 spot17 // π 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 Context31 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 */2import { NgtStore } from "angular-three";3
4export 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 π!
1export 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
1export class Model {2 // Model now accepts an Input for renderPriority to customize the order of the code that runs in the animation loop3 @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.
1export class Model {2 @Input() renderPriority = 0;3
4 constructor() {5 // This won't work because `renderPriority` is always 06 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.
1export class Model {2 @Input() renderPriority = 0;3
4 ngOnInit() {5 // This won't work because `injectBeforeRender` is invoked outside of an Injection Context6 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 */2import { NgtStore } from "angular-three";3 import { assertInInjectionContext } from "@angular/core";4
5export 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 */2import { NgtStore } from "angular-three";3import { assertInInjectionContext } from "@angular/core";4
5export 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
1export function assertInjector(fn: Function, injector?: Injector): Injector {2 // we only call assertInInjectionContext if there is no custom injector3 !injector && assertInInjectionContext(fn);4 // we return the custom injector OR try get the default Injector5 return injector ?? inject(Injector);6}
With this, we can update our CIF as follow
1/* extra typings in this snippet are irrelevant */2import { NgtStore } from "angular-three";3 import { assertInjector } from './assert-injector';4
5export 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 default12 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
1export class Model {2 @Input() renderPriority = 0;3
4 constructor() {5 // β
no renderPriority, everything works as before6 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 well15 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 Context26 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!