inject() and DestroyRef quietly killed my ngOnDestroy boilerplate
Constructor injection, takeUntil subjects, and ngOnDestroy cleanup — three rituals modern Angular DI replaced with inject(), DestroyRef, and takeUntilDestroyed(). What changed, and the injection-context rule you must respect.
There's a constructor I've written a thousand times and don't write anymore:
constructor(
private http: HttpClient,
private router: Router,
private route: ActivatedRoute,
private store: Store,
private destroy$: Subject<void>, // ...and the ritual that goes with it
) {}Plus the matching ritual at the bottom of the class: ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }, and takeUntil(this.destroy$) sprinkled on every subscription, and a code review comment when someone forgot one. None of that survives in the code I write today, and the replacements are three small APIs that compose into something genuinely better.
inject(): not just nicer syntax
export class UserPanel {
private http = inject(HttpClient);
private route = inject(ActivatedRoute);
}The aesthetic win is obvious. The structural wins are the reason to switch. Inheritance stops hurting: a base class that uses inject() doesn't force every subclass to declare and forward constructor parameters — if you've ever touched a base component used by thirty subclasses and then spent the day updating thirty constructors, that sentence is the whole argument. Functional everything: guards, resolvers and interceptors are plain functions now, and inject() is the only way they get dependencies:
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn() || router.createUrlTree(['/login']);
};And the one that changed how I structure code — composition helpers. Because any function called from an injection context can itself call inject(), you can extract cross-cutting patterns into plain functions:
export function injectQueryParam(name: string): Signal<string | null> {
const route = inject(ActivatedRoute);
return toSignal(
route.queryParamMap.pipe(map(p => p.get(name))),
{ initialValue: null },
);
}
// in any component:
search = injectQueryParam('q');That's dependency injection, RxJS interop and cleanup bundled in a named, testable, reusable unit. The React folks call these hooks and built a whole culture on them; Angular got the same power almost quietly.
DestroyRef: cleanup without the ceremony
DestroyRef is injectable like anything else, and its one job is to run callbacks when the current scope is destroyed:
export class ChartPanel {
private destroyRef = inject(DestroyRef);
constructor() {
const chart = renderChart(this.el.nativeElement);
this.destroyRef.onDestroy(() => chart.dispose());
}
}Notice what's gone: implementing OnDestroy, the override chain when a base class also had cleanup, the distance between acquiring a resource and releasing it. Setup and teardown sit next to each other — the resource's whole lifecycle is readable in one place. And because helpers can inject it, your composition functions clean up after themselves without the caller knowing.
takeUntilDestroyed(): the destroy$ ritual, retired
export class PriceTicker {
private ws = inject(WebSocketService);
constructor() {
this.ws.prices$
.pipe(takeUntilDestroyed())
.subscribe(p => this.render(p));
}
}Called in a constructor or field initializer, it needs zero arguments — it picks up DestroyRef from the injection context by itself. The destroy$ subject, its two-line ngOnDestroy, and the review comment about the missing takeUntil: all deleted. If you use it outside the constructor (say, in a callback), pass the ref explicitly — takeUntilDestroyed(this.destroyRef) — which brings us to the rule.
The one rule that bites everyone once
inject() only works in an injection context: field initializers, the constructor, provider factories. Not in ngOnInit. Not in a setTimeout. Not in your event handler. The error message (NG0203) is famous enough to be a meme, and everyone earns it exactly once.
ngOnInit() {
const http = inject(HttpClient); // ❌ NG0203 — too late, context is gone
}The mental model that makes it stick: the injection context isn't magic, it's a moment — the moment your class is being constructed, when Angular knows which injector is "current". After construction, that knowledge is gone. So grab everything you need during construction (field initializers are the natural place), and if you genuinely must resolve dependencies later, that's what runInInjectionContext(injector, fn) is for — an explicit, greppable escape hatch instead of an accident.
Put together, the pattern I now consider baseline for any component: dependencies as inject() field initializers, subscriptions wearing takeUntilDestroyed(), ad-hoc resources registered with DestroyRef.onDestroy(), and anything repeated twice extracted into an injectX() helper. Less ceremony, but that's the surface benefit. The real one is that lifecycle bugs — the leaked subscription, the chart that outlives its tab — stopped being a category of bug I find in review. The framework didn't make them rarer; it made them hard to write.