Dependency injection
A hierarchical injector resolves services by scope: each module and each mounted view gets its own injector, parents delegate upward, and where a provider is registered decides who shares the instance.
Providers
Four provider forms, same as Angular:
@Module({
providers: [
TodoStore, // class → singleton per scope
{ provide: API_URL, useValue: '/api/v2' }, // value
{ provide: Logger, useClass: HttpLogger }, // alias another class
{ provide: QueryClient, useFactory: () => new QueryClient() }, // factory
{ provide: Clock, useFactory: (api: Api) => new Clock(api), deps: [Api] },
],
})
class AppModule {}InjectionToken<T> gives non-class dependencies a typed, rename-safe key:
export const API_URL = new InjectionToken<string>('API_URL');
// consumer side
private apiUrl = inject(API_URL);inject() — construction-time only
inject() reads from the injector that is currently constructing the instance. It belongs in field initializers or the constructor — never in render(), callbacks, or async work, where it throws No active injection context:
@Component()
export class TodoView extends View {
private store = inject(TodoStore); // field initializer — correct
private c = inject(TodoController); // resolved in this view's scope
render() {
// inject(Anything) here would throw — render is not a context
return <p>{this.store.todos.value.length}</p>;
}
}For code outside construction — a factory, an integration helper — wrap the call in runInInjectionContext(injector, fn), or read the injector with getCurrentInjector() during construction and call injector.get(token) later.
Scoping — where a provider lives decides its lifetime
| Registered on | Instance lifetime |
|---|---|
providedIn: 'root' on @Injectable | One for the whole app, resolved from the root injector |
@Module({ providers }) | One per module injector — shared by every route, view, and controller under that module |
@Component({ providers }) | One per mounted view instance — the view's own Angular-style component providers |
Declared controllers (module or component) | Shared down that injector's subtree |
Undeclared @Controller / injectable | Transient — a fresh instance per injecting scope |
Resolution walks the chain view → route module → app → root. A closer registration shadows a farther one — an Angular-style override at any level.
Lifecycle and cleanup
Every injector owns a DestroyRef. Inject it during construction and register teardown — it fires when the scope deactivates (view unmounts, module injector is released):
@Injectable()
export class ThemeService {
theme = signal<Theme>(this.readStored());
constructor() {
const mq = matchMedia('(prefers-color-scheme: dark)');
const onChange = () => this.theme.value = 'system';
mq.addEventListener('change', onChange);
inject(DestroyRef).onDestroy(() =>
mq.removeEventListener('change', onChange));
}
}Views and controllers implement onInit()/onDestroy() — called when their scope activates and deactivates, guaranteed paired even on same-tick mount/unmount. See Views & lifecycle.