The Angular pattern that needs no Provider
Services are often the default abstraction for reusable Angular logic. But what if the logic is local to a component, depends on its injection context, and does not represent shared application state? Consider a dashboard widget that knows whether its host element is currently visible: @Component({ selector: 'app-dashboard-widget', template: {{ visible() ? 'Awake' : 'Sleeping' }}, }) export class DashboardWidget { readonly visible = injectVisibility(); } That information can be used to avoid work the user cannot currently see. For example, we can avoid creating an expensive part of the template: @if (visible()) { } Or pause and resume an asynchronous process: readonly data = toSignal( toObservable(this.visible).pipe( switchMap(visible => visible ? timer(0, 5000).pipe( switchMap(() => this.api.loadStatistics()), ) : EMPTY ), ), ); The same pattern can suspend polling, animations, data transformations or expensive visualizations while the component is outside the viewport. There is no directive in the template, no lifecycle hook and, most importantly, no provider to configure. Let’s see how we get there. The component-scoped service A first solution could be a service that injects the component’s ElementRef , creates an IntersectionObserver and exposes the result as a Signal. @Injectable() export class VisibilityService { private readonly host = inject >(ElementRef).nativeElement; private readonly destroyRef = inject(DestroyRef); private readonly state = signal(false); readonly visible = this.state.asReadonly(); constructor() { const observer = new IntersectionObserver(([entry]) => { this.state.set(entry?.isIntersecting ?? false); }); observer.observe(this.host); this.destroyRef.onDestroy(() => observer.disconnect()); } } For the service to resolve the correct host element, however, it must be created in the component’s element injector: @Component({ providers: [VisibilityService], }) export class DashboardWidget { readonly visible = inject(VisibilityService).visible; } This works, but the API has a hidden requirement: every consumer must remember to declare the provider. Forget it, and the feature stops working. A root service would not solve the problem either. A singleton has no single component host associated with it. The host directive A host directive models the responsibility more accurately: @Component({ hostDirectives: [VisibilityDirective], }) export class DashboardWidget { readonly visible = inject(VisibilityDirective).visible; } The behavior is nicely encapsulated, but we still have metadata boilerplate: hostDirectives: [VisibilityDirective] For behavior that should also be selectable from a template, this is perfectly reasonable. But our component only needs a contextual value: a Signal tied to its own host and lifecycle. Do we really need another injectable instance for that? A custom inject function Angular’s inject() API is not limited to services. It can also be used inside a function called while an injection context is active, for example during a component field initializer. That lets us implement the entire feature as a function: import { DestroyRef, ElementRef, Signal, assertInInjectionContext, inject, signal, } from '@angular/core'; export function injectVisibility( options: IntersectionObserverInit = {}, ): Signal { assertInInjectionContext(injectVisibility); const host = inject >(ElementRef).nativeElement; const destroyRef = inject(DestroyRef); const visible = signal(false); const observer = new IntersectionObserver(([entry]) => { visible.set(entry?.isIntersecting ?? false); }, options); observer.observe(host); destroyRef.onDestroy(() => { observer.disconnect(); }); return visible.asReadonly(); } Usage becomes: @Component({ selector: 'app-dashboard-widget', template: {{ visible() ? 'Awake' : 'Sleeping' }}, }) export class DashboardWidget { readonly visible = injectVisibility({ rootMargin: '200px', }); } The function: - finds the consumer’s host element; - creates private state for that consumer; - connects the cleanup to its lifecycle; - returns a normal readonly Signal. No service instance or component metadata is required. Why the call location matters This works because field initializers run while Angular is creating the component: export class DashboardWidget { readonly visible = injectVisibility(); } Calling the same function later does not work: export class DashboardWidget { startObserving() { this.visible = injectVisibility(); // Error } } By then, the component creation context has ended. assertInInjectionContext() makes this constraint immediately clear to consumers of the API. When should you use this pattern? A custom inject function is a good fit when the feature: - depends on the caller’s injection context; - creates state local to each consumer; - needs contextual dependencies such as ElementRef orDestroyRef ; - does not represent a shared service; - can expose its result through an ordinary value, Signal or Observable. A service remains the better abstraction when it has shared state, a meaningful identity, or implementations that consumers need to replace through dependency injection. The important distinction is not whether some code uses Angular DI. It is whether that code needs to be an injectable object. Sometimes the cleanest injectable Angular API is just a function. Top comments (0)
Comments
No comments yet. Start the discussion.