DEV Community

Cover image for The Angular pattern that needs no Provider

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: `
    <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 ElementRef or DestroyRef;
  • 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)