An Angular helper function calls inject() inside a setTimeout callback and throws NG0203 when the timer fires; why, and how do you fix it?
answer
- context is a synchronous stack frame
- callback runs on a fresh stack
- resolve first, use later
- capture Injector for late lookups
- await ends the context too
basics
~20 sAn injection context lasts only for the synchronous call that created it; the setTimeout callback runs later on a fresh stack with no current injector. Call inject() before scheduling, or capture an Injector and use runInInjectionContext.
solid answer
~40 s`inject()` reads a current-injector slot that Angular fills only for the duration of a synchronous call such as construction. A helper called from a field initializer is in context while its own body runs, but anything it schedules — a `setTimeout` callback, a `.then()`, a subscription, code after an `await` — runs on a new stack after Angular has restored the slot, so `inject()` there throws NG0203. The fix is to resolve dependencies up front: call `inject(DraftStore)` at the top of the helper and let the callback close over the instance. If the token truly is only known later, capture `inject(Injector)` synchronously and wrap the late code in `runInInjectionContext(injector, () => …)`. Adding `assertInInjectionContext(helper)` at the top makes misuse fail immediately with the helper's name in the message.
code
ts · 31 linesimport { Component, DestroyRef, Injectable, assertInInjectionContext, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DraftStore {
save(): void {
/* persist the current draft */
}
}
// Broken: the callback runs later, outside any injection context.
export function scheduleAutosaveBroken(delayMs: number): void {
setTimeout(() => inject(DraftStore).save(), delayMs); // NG0203 when the timer fires
}
// Fixed: resolve synchronously, use later, clean up on destroy.
export function scheduleAutosave(delayMs: number): void {
assertInInjectionContext(scheduleAutosave);
const drafts = inject(DraftStore);
const handle = setTimeout(() => drafts.save(), delayMs);
inject(DestroyRef).onDestroy(() => clearTimeout(handle));
}
@Component({
selector: 'app-article-editor',
template: `<textarea></textarea>`,
})
export class ArticleEditor {
constructor() {
scheduleAutosave(5000); // called during construction: in context
}
}go deeper
Know that inject() must run immediately during construction, so resolve services first and use them in callbacks.
Explain the context as a synchronous stack property and list which callbacks lose it: timers, promises, awaits, subscriptions.
Fix helpers by resolving up front, use a captured Injector with runInInjectionContext only for late decisions, and add assertInInjectionContext and destroy cleanup.
Define how shared helper libraries handle DI: injection at call time, explicit context assertions, and no module-level injector shortcuts.
## The failing helper A team writes a small helper that autosaves a draft a few seconds after a form component is created: ```ts export function scheduleAutosave(delayMs: number) { setTimeout(() => { inject(DraftStore).save(); // NG0203 when the timer fires }, delayMs); } export class ArticleEditor { constructor() { scheduleAutosave(5000); } } ``` The helper is called from a constructor, so it seems to be "in context". It is — for the instant its own body runs. The callback is a different story. ## Why the callback has no context An **injection context** is a property of the synchronous call stack. While Angular constructs `ArticleEditor`, it sets the current injector; every function called synchronously from the constructor, including `scheduleAutosave`, can use `inject()`. When the constructor returns, Angular restores the previous (empty) state. `setTimeout` does not call the callback now. It queues it. Five seconds later the JavaScript event loop invokes the callback on a fresh stack, long after construction finished. There is no current injector, so `inject(DraftStore)` throws NG0203. The same applies to every deferred path: - `setTimeout`, `setInterval`, `requestAnimationFrame` callbacks; - `Promise.then` callbacks and anything after an `await`; - RxJS `subscribe` callbacks and operators that run on later emissions; - DOM event listeners added manually. `runInInjectionContext` has the same limit. Its own documentation says `inject()` is "only usable synchronously, and cannot be used in any asynchronous callbacks or after any `await` points". ## Fix 1: resolve first, use later (preferred) ```ts export function scheduleAutosave(delayMs: number) { const drafts = inject(DraftStore); // synchronous, in context setTimeout(() => drafts.save(), delayMs); } ``` The callback closes over the resolved instance. This is the default fix: dependencies are visible at the top of the helper, and the deferred code does not need DI at all. ## Fix 2: capture an injector for late decisions Sometimes the token is chosen later — for example, the callback picks a storage strategy based on data it has only then. Capture an `Injector` while in context and re-enter a context later: ```ts const injector = inject(Injector); setTimeout(() => { runInInjectionContext(injector, () => inject(pickStore(kind)).save()); }, delayMs); ``` `runInInjectionContext` sets the given injector as current for the synchronous duration of the function and returns its result. Inside a component, `inject(Injector)` returns the component's element injector, so the late lookup resolves exactly as the component would have. If the captured injector is an environment injector that has since been destroyed, the call throws NG0205 instead. ## Fail fast with `assertInInjectionContext` A helper that needs a context should say so on its first line: ```ts assertInInjectionContext(scheduleAutosave); ``` Called outside a context, it throws NG0203 with a message that starts with the helper's own name — "scheduleAutosave() can only be used within an injection context…" — pointing at the real mistake, the call site, instead of at an `inject()` deep inside. ## Clean up what you schedule A timer started from a component outlives it unless cancelled. Register cleanup with `inject(DestroyRef).onDestroy(() => clearTimeout(handle))` while still in context, so a destroyed editor does not save a stale draft. ## Finding the frame that lost the context When NG0203 appears in a helper, walk the stack trace from the `inject()` call outward: 1. Identify the function that contains the failing `inject()` call. 2. Check whether that function is a callback: a timer, a promise continuation, a subscription, an event listener, or code after an `await`. 3. If it is, find the synchronous frame that *scheduled* it — that is where the dependency should be resolved. 4. If it is not a callback, find where the helper itself is called; a helper called from a method or hook is the real bug, and `assertInInjectionContext` would have reported it there. ## Summary table | Pattern | Works? | Why | |---|---|---| | `inject()` at the top of the helper, used in the callback | yes | resolved synchronously in context | | `inject()` inside the callback | no | callback runs on a fresh stack | | `runInInjectionContext(captured, …)` inside the callback | yes, synchronously | re-enters a context with the captured injector | | `inject()` after `await` in a context-wrapped async function | no | code after `await` runs later |
- Would wrapping the setTimeout call itself in runInInjectionContext fix the broken helper?No. `runInInjectionContext` keeps the context only while its function runs synchronously; `setTimeout` merely schedules the callback and returns, so the context is gone before the callback fires. The wrapper has to be inside the callback, around the code that calls `inject()`, using an injector captured earlier.
- Where is the injector captured by inject(Injector) inside a component pointing?At the component's element injector, which falls back through ancestor element injectors and then the environment injectors. A later `runInInjectionContext` with it resolves tokens the same way the component's own field initializers would have.
saying these in an interview costs you the question
- A helper called from a constructor keeps its context in all callbacks
- Wrapping setTimeout in runInInjectionContext covers the delayed callback
- Code after await in a context-wrapped async function can still inject
- Passing optional: true to inject() avoids NG0203
- NG0203 in a callback means DraftStore is missing a provider