The Component-Scoped Service

Services frequently serve as the go-to solution for reusable Angular logic. However, there are situations where the logic is confined to a particular component, relies on the injection context, and does not embody shared application state.

Imagine a dashboard widget that detects whether its host element is within the current viewport.

@Component({
  selector: 'app-dashboard-widget',
  template: `
    <p>{{ visible() ? 'Awake' : 'Sleeping' }}</p>
  `,
})
export class DashboardWidget {
  readonly visible = injectVisibility();
}

This visibility data can prevent unnecessary work that users cannot perceive.

For instance, one could delay the creation of a costly template section:

@if (visible()) {
  <app-expensive-chart />
}

Alternatively, an asynchronous operation could be paused and later resumed:

readonly data = toSignal(
  toObservable(this.visible).pipe(
    switchMap(visible =>
      visible
        ? timer(0, 5000).pipe(
            switchMap(() => this.api.loadStatistics()),
          )
        : EMPTY
    ),
  ),
);

This approach can also halt polling, animation loops, data processing, or complex chart rendering when the component leaves the visible area.

Notably, this requires no directive, no lifecycle hook, and crucially, no provider setup.

Here is how to achieve it.

The Component-Scoped Service

A straightforward solution is a service that obtains the component's ElementRef, sets up an IntersectionObserver, and provides the outcome 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());
  }
}

However, to ensure the service identifies the correct host element, it must be instantiated within the component's element injector:

@Component({
  providers: [VisibilityService],
})
export class DashboardWidget {
  readonly visible = inject(VisibilityService).visible;
}

This functions correctly, but the API has an implicit prerequisite: each consumer must recall to declare the provider. Forgetting to do so will silently break the feature.

A root-level service isn't a viable alternative, as a singleton cannot be associated with a specific component host.

The Host Directive

A host directive more accurately represents the responsibility:

@Component({
  hostDirectives: [VisibilityDirective],
})
export class DashboardWidget {
  readonly visible = inject(VisibilityDirective).visible;
}

While the behavior is well-encapsulated, the required metadata is still verbose:

hostDirectives: [VisibilityDirective]

This is perfectly acceptable for behavior that needs to be invoked via a template selector. Yet, our component merely requires a contextual value—a Signal tied to its own host element and lifecycle. Is an additional injectable instance truly necessary for this?

A Custom Inject Function

Angular's inject() function is not reserved exclusively for services. It can also be invoked within a function that is called while an injection context is active—for example, during a component's field initialization.

This enables us to implement the entire feature as a standalone 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();
}

The usage then becomes:

@Component({
  selector: 'app-dashboard-widget',
  template: `
    <p>{{ visible() ? 'Awake' : 'Sleeping' }}</p>
  `,
})
export class DashboardWidget {
  readonly visible = injectVisibility({
    rootMargin: '200px',
  });
}

This function:

  • locates the host element of the consumer;
  • establishes isolated state specific to that consumer;
  • binds the cleanup to the consumer's lifecycle;
  • returns a standard readonly Signal.

No service instance or component metadata is needed.

The Importance of Call Location

This method succeeds because field initializers execute during component creation:

export class DashboardWidget {
  readonly visible = injectVisibility();
}

Attempting to call the same function later will fail:

export class DashboardWidget {
  startObserving() {
    this.visible = injectVisibility(); // Error
  }
}

At that point, the component's creation context is no longer available. The assertInInjectionContext() call makes this requirement immediately apparent to those using the API.

When to Apply This Pattern

A custom inject function is most suitable when the feature:

  • relies on the injection context of the caller;
  • generates state that is private to each consumer;
  • requires context-specific dependencies such as ElementRef or DestroyRef;
  • does not represent a shared or singleton service;
  • can return its output as a plain value, Signal, or Observable.

A service, on the other hand, is the better choice when there is shared state, a distinct identity, or when implementations may be swapped out through dependency injection.

The key distinction isn't whether the code uses Angular DI. It's whether the code must be an injectable object. Sometimes, the most polished injectable Angular API is simply a function.