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: `
<p>{{ visible() ? 'Awake' : 'Sleeping' }}</p>
`,
})
export class DashboardWidget {
readonly visible = injectVisibility();
}
Enter fullscreen mode Exit fullscreen mode
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()) {
<app-expensive-chart />
}
Enter fullscreen mode Exit fullscreen mode
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
),
),
);
Enter fullscreen mode Exit fullscreen mode
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<HTMLElement>>(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());
}
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
The behavior is nicely encapsulated, but we still have metadata boilerplate:
hostDirectives: [VisibilityDirective]
Enter fullscreen mode Exit fullscreen mode
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<boolean> {
assertInInjectionContext(injectVisibility);
const host =
inject<ElementRef<HTMLElement>>(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();
}
Enter fullscreen mode Exit fullscreen mode
Usage becomes:
@Component({
selector: 'app-dashboard-widget',
template: `
<p>{{ visible() ? 'Awake' : 'Sleeping' }}</p>
`,
})
export class DashboardWidget {
readonly visible = injectVisibility({
rootMargin: '200px',
});
}
Enter fullscreen mode Exit fullscreen mode
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();
}
Enter fullscreen mode Exit fullscreen mode
Calling the same function later does not work:
export class DashboardWidget {
startObserving() {
this.visible = injectVisibility(); // Error
}
}
Enter fullscreen mode Exit fullscreen mode
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
ElementReforDestroyRef; - 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.