
Angular Inject & Injection Functions - Patterns & Anti-Patterns

š§ The State Management Use Case
EffectsModule.forRoot(myEffects)
EffectsModule.forFeature(myEffects)
Grouping with Modules
UnreadEmailsBadgeComponent
EmailListComponent
emailEffects
@NgModule({
declarations: [UnreadEmailsBadgeComponent, EmailListComponent],
exports: [UnreadEmailsBadgeComponent, EmailListComponent],
imports: [EffectsModule.forFeature(emailEffects)]
})
class EmailModule {}
Facade Module
EmailModule
@Injectable()
class EmailFacade {
unreadEmails$ = this._store.select(selectUnreadEmails());
constructor(private _store: Store) {}
...
}
@NgModule({
imports: [EffectsModule.forFeature(emailEffects)],
providers: [EmailFacade]
})
class EmailModule {}
The Problem Without Modules
emailsEffects
@Component({
providers: [provideEffects(emailEffects)]
})
class UnreadEmailsBadgeComponent {}
Alex's PR
inject()
inject()
14.0.0-rc.1
inject() + InjectionToken solution
inject()
const EmailStore = new InjectionToken<Store>('EmailStore', {
factory() {
const store = inject(Store);
store.registerEffects(emailEffects);
return store;
}
});
@Component(...)
class UnreadEmailsBadgeComponent {
/* `store` type is `Store` thanks to type inference
* and `InjectionToken` being a generic. */
store = inject(EmailStore);
}
@Inject()
constructor(@Inject(EmailStore) store: Store)
ā ļø WARNING: inject() can only be used in construction context (i.e. in the call stack of constructor()
and of course fields initialization).
š The Boilerplate Reduction Use Case
Injection Functions
inject()
An Angular Injection Function is a synchronous function that directly or indirectly injects services using the inject()
function. Angular Injection Functions can only be used in the construction context of a declarable (e.g. component, directive, pipe) or a service.
@Component(...)
class RecipesComponent {
addRecipe = injectAction(addRecipe);
recipes$ = injectSelection(selectRecipes);
}
function injectAction(action) {
return (...) => inject(Store).dispatch(action);
}
function injectSelection(selector) {
return inject(Store).select(selector);
}
By following the principle of least surprise, I'd recommend prefixing Injection Function with inject
. This will also help implement lint rules to detect Injection Function misuse.
šŖ The Type Inference Use Case
@Inject()
constructor(@Inject(HttpClient) http: number) {}
Alternative
inject()
InjectionToken
const MyToken = new InjectionToken<number>('MyToken');
@Component(...)
class MyCmp {
value = inject(MyToken); // value type is number
}
šŗ The Abstract Base Class Use Case
inject()
@Directive()
abstract class Base {
a = inject(A);
}
@Component()
class Cmp extends Base {
constructor(b: B) {
super();
}
}
inject()
ā³ Anti-Pattern #1: Hacking Declarable's Provider
RxState
ComponentStore
ngOnDestroy
@Component({
providers: [RxState]
})
class MyCmp {
constructor(private _state: RxState) {}
}
@Self()
function injectState(initialState) {
const config = inject(RxStateConfig);
const state = new RxState(config);
state.set(initialState);
return state;
}
@Component(...)
class MyCmp {
state = injectState(initialState);
}
Detecting onDestroy
Lifecycle Hook
onDestroy
function injectState() {
/* @hack wrongly assuming that ChangeDetectorRef is a ViewRef
* Yeah! CDR is actually the ViewRef of the component. */
const viewRef = inject(ChangeDetectorRef) as ViewRef;
/* Initialize state. */
const state = new RxState();
state.set(initialState);
/* Unsubscribe from everything on destroy.
* @hack queue microtask otherwise this breaks in devMode due
* to the following condition: https://github.com/angular/angular/blob/0ff4eda3d4bd52fb145285a472e9c0237ea8e68f/packages/core/src/render3/instructions/shared.ts#L804-L806
* Credits to Chau Tran for raising the issue.*/
queueMicrotask(() => viewRef.onDestroy(() => state.ngOnDestroy()));
}
2022-06-02 UPDATE - ā ļø WARNING: Downcasting ChangeDetectorRef
to ViewRef
is a very problematic hack and might break at any time, even in a future Angular minor version. This should never be used in production. Also, scheduling the onDestroy
in a microtask is very fragile. In fact, not only it's quite unpleasant to read, but there might be some extreme cases where the callback might not be called.
Hooking Into Change Detection
ChangeDetectionStrategy.OnPush
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div>{{ state.counter }}</div>
<div>
<button (click)="state.counter = 0">RESET</button>
<button (click)="increment()">+</button>
</div>`,
})
export class CounterComponent {
state = injectState({
counter: 0,
});
increment() {
/* Adding a dirty setTimeout to show that reactivity works anyway. */
setTimeout(() => {
this.state.counter += 1;
}, 500);
}
}
ā ļø WARNING: While it is fun to experiment with this kind of hacks and observe their impact on DX, performance etc... they should be avoided for a couple of reasons that I regrouped at the end of this article.
Scoped Providers
@Component({
providers: [
{
provide: State,
useClass: State,
scope: 'self'
}
]
}
class MyCmp {
}
š Anti-Pattern #2: Hacking Dependency Injection
Configurable Dependency
@Injectable()
class Paginator {
constructor(@Self() config: PaginatorConfig, http: HttpClient) {}
}
@Injectable()
abstract class PaginatorConfig {
limit: number;
}
@Component({
providers: [
Paginator,
{
provide: PaginatorConfig,
useValue: {
limit: 10
} as PaginatorConfig
}
]
})
class MyCmp {
constructor(private _paginator: Paginator) {
}
}
The Factory
@Component(...)
class MyCmp {
paginator = this._factory.create({limit: 10});
constructor(private _factory: PaginatorFactory) {}
}
@Injectable({providedIn: 'root'})
class PaginatorFactory {
constructor(private _http: HttpClient) {}
create(config: PaginatorConfig) {
return new Paginator(config, {http: this._http});
}
}
Factory Injection Function
@Component(...)
class MyCmp {
paginator = injectPaginator({limit: 10});
}
function injectPaginator(config) {
return new Paginator(config);
}
class Paginator {
http = inject(HttpClient);
constructor(config) {}
}
2022-06-02 UPDATE - ā ļø WARNING: While this less verbose approach is quite attractive, it is still quite hacky. First, the HttpClient
is not very explicit, and one might be surprised when trying to instantiate Paginator
manually. Also, instantiating the Paginator
manually is kind of defeating the purpopse of Angular dependency injection. Finally, we don't have any proper approach to hook into ngOnDestroy
lifecycle hook.
šŖ Anti-Pattern #3: Hacking Lifecycle Hooks
ngOnDestroy
@Component(...)
class MyCmp {
hold = injectHolder();
ngOnInit() {
/* This will automatically unsubscribe on */
this.hold(interval(1000), value => console.log(value));
}
}
function injectHolder() {
const subscription = new Subscription();
const viewRef = inject(ChangeDetectorRef) as ViewRef;
queueMicrotask(() => viewRef.onDestroy(subscription.unsubscribe()));
return (source$, observer) => {
const sub = source$.subscribe(observer);
subscription.add(sub);
return sub;
}
}
2022-06-02 UPDATE - ā ļø WARNING: Well, just like the two previous anti-patterns, less boilerplate is always exciting but at what cost? In fact, this can break in some extreme cases or simply in a future major or even minor version of Angular.
š
°ļø Alex Rickabaugh's Recommendations
ā Stick with APIs that require the user to add something to providers in order to be able to inject it. ā Contain side effects in the factories of those providers. ā Use inject as a convenience in constructors to avoid the awkwardness of @Inject
when dealing with InjectionToken
. ā Don't try to build "magical" Injection Functions that secretly create things outside the DI system.
š Additional Clarifications & Recommendations
ā inject()
and Injection Functions are not meant to replace traditional DI . They are only here to solve the issues described above. ā inject()
and Injection Functions are not the building blocks for React-like hooks . They could allow something similar to Vue's Composition API but this is not the goal here. ā Injection Functions are very contextual. They simply don't work outside of the factory or constructor call stack. That's the reason why we need a very explicit naming convention where we can easily tell that this only works in this context because it's using inject()
underneath. I'd recommend using the inject
prefix. This way it should be easy to implement a fast and reliable lint rule ensuring that inject()
and Injection Functions are used in the right place. ā Using traditional Dependency Injection or inject()
or Injection Functions is an implementation detail. Switching from one to another shouldn't affect tests. This is the reason why tests, especially "isolated tests" should not be instantiating components manually (e.g. new MyCmp(httpClient, state)
) and should be using the TestBed
. In addition to this, we don't want our tests to get coupled to the order of the injected services and we might not want to provide test doubles for all dependencies.
TestBed.configureTestingModule({providers: [MyCmp, ...testDoubles]});
const cmp = TestBed.inject(MyCmp);