Angular components and DI
covers standalone components · lifecycle hooks & order · signal inputs, outputs, model() · injector hierarchy · providers & injection tokens · content projection & query timing
Standalone components are the default now
Since Angular 19.0.0 (and current in v22), a component with no standalone property is standalone. It lists the other components, directives, and pipes it uses in its own imports array instead of relying on an NgModule's declarations.
@Component({
selector: 'user-card',
imports: [DatePipe, AvatarComponent], // this component's own template deps
template: `{{ createdAt | date }} <app-avatar />`,
})
export class UserCard {}
An app boots with bootstrapApplication(AppRoot, appConfig) and no root NgModule. NgModules still work and interoperate, so migration goes component by component, but for new code they are the legacy path.
Wrong "A new component needs standalone: true to skip NgModules." That was the opt-in flag before 19.0.0. From 19.0.0 on the default flipped, so you write standalone: false only to keep a component inside the old NgModule world.
Lifecycle hooks and their order
Two orderings get probed. ngOnChanges fires before ngOnInit, so inputs are already set by the time ngOnInit runs. And content initializes before view: content is what a parent projects into your <ng-content>, view is your own template, so ngAfterContentInit runs once, then ngAfterViewInit runs once.
Wrong "Read an input in the constructor to seed the component." Inputs are not bound when the constructor runs, so the value is undefined there. The first hook where an input is readable is ngOnChanges, and it is set by ngOnInit; put constructor-style setup that depends on an input into ngOnInit.
Signal inputs, outputs, and model()
The current API is functions, not decorators. input() returns a read-only signal you call to read; output() returns an emitter.
export class Slider {
value = input(0); // InputSignal<number>, read via value()
max = input.required<number>(); // no default: the parent MUST bind it
changed = output<number>(); // emit with changed.emit(n)
}
Read a signal input by calling it: this.value(). It is read-only, so the child cannot assign to it. The template binding is unchanged: <app-slider [value]="x" (changed)="onChange($event)" />. The older @Input() / @Output() decorators still work and behave the same way from the template side.
Wrong "Signal inputs are set instantly, so I can read max() in the constructor." A required input has no value until Angular binds it after construction, and reading it there throws. Read inputs in ngOnInit, or derive from them reactively with computed / effect (reactivity is its own topic).
For two-way binding use model():
value = model(0); // creates the value signal AND a valueChange output
// parent: <app-slider [(value)]="x" />
model() returns a writable signal. The child updates it with set / update, and the new value flows back to the parent's [(value)]. Angular derives the paired output by suffixing the name with Change, which is what makes the banana-in-a-box syntax work.
Getting a service into a component
Reach for inject() in a field initializer:
export class Checkout {
private cart = inject(CartService);
}
There are two ways to make CartService resolvable, and they mean different things. @Injectable({ providedIn: 'root' }) gives one app-wide instance, created on first injection and dropped from the bundle if nothing uses it. Listing it in a component's providers: [CartService] gives that component and its subtree their own separate instance.
Wrong "It's providedIn: 'root', but I'll also add it to providers to be safe." That does not reuse the root instance, it creates a second one scoped to the component's element injector. Two callers then hold different state and one looks empty. Pick one: root for a shared singleton, component providers for deliberate per-instance state.
The injector hierarchy and how resolution walks it
Angular keeps two injector trees. Element injectors are created per component and directive element and hold whatever that decorator's providers and viewProviders declare. Environment injectors are the app-level chain: the root environment injector from bootstrapApplication, its parent the platform injector, and finally the NullInjector.
When you inject(X), Angular searches the current element injector, walks up the parent element injectors to the root component's element injector, and only then crosses into the environment injector chain up to NullInjector, which throws unless the request is optional.
Modifiers change the walk. { self: true } looks only at the current element injector. { skipSelf: true } starts one level up. { host: true } stops at the host component's element injector. { optional: true } returns null instead of throwing when the walk reaches NullInjector. The same options exist as the @Self / @SkipSelf / @Host / @Optional parameter decorators for constructor injection.
providedIn root vs component providers
providedIn: 'root' registers the service in the root environment injector: one instance for the whole app, created lazily on first injection, and tree-shaken out if nothing injects it. A providers entry on a component registers the service in that element injector instead, giving a fresh instance scoped to the component and everything below it.
Wrong "providedIn: 'root' guarantees a single instance no matter what." It guarantees the root instance is a singleton, but a providers: [Svc] on any component creates a separate instance in that element injector, and resolution finds the local one first because element injectors are searched before the environment injector. If you want isolation, that override is the tool. If you want one shared instance, do not also list the service in component providers, or you will silently run two.
providers versus viewProviders splits on projected content. A service in providers is visible to the component's own view and to content projected through <ng-content>; a service in viewProviders is visible only to the component's own view, not to projected content.
Angular 22 adds @Service() as an ergonomic shorthand for @Injectable({ providedIn: 'root' }). It supports only inject() (no constructor injection) and a single factory option (no useClass / useValue / useFactory). Keep @Injectable when you need constructor DI, a non-root scope, or the advanced provider keys.
Injection tokens and inject() vs constructor
For anything that is not a class (a config object, a string, a primitive) you need an InjectionToken as the DI key.
export const DATE_FORMAT = new InjectionToken<string>('date.format');
// provide: { provide: DATE_FORMAT, useValue: 'yyyy-MM-dd' }
// read: private fmt = inject(DATE_FORMAT);
Wrong "Key it on a string like 'DATE_FORMAT', or type it with an interface." A string token collides across libraries and carries no type, so the injected value is any. A TypeScript interface cannot be a token at all: interfaces are erased at compile time, so nothing exists at runtime for DI to key on. InjectionToken<T> is a runtime object identity plus a generic type, and it takes an optional providedIn + factory so it can tree-shake like a root service.
inject() and constructor parameters resolve against the same injectors; the difference is where you can call them. inject() runs only inside an injection context: class construction, field initializers, provider factories, and runInInjectionContext. It is disallowed in methods and lifecycle hooks, so cache what you need into a field at construction time. inject() also composes better with base classes and lets you type the result without a constructor parameter.
Query timing: ViewChild, ContentChild, signal queries
Decorator queries resolve on a schedule tied to the lifecycle. A @ContentChild is available in ngAfterContentInit; a @ViewChild is available in ngAfterViewInit; before those hooks each is undefined. The { static: true } option moves a view query up to ngOnInit, but only for a target that is never inside @if / @for, and a static result never updates afterward.
Wrong "@ViewChild is populated by ngOnInit." Not for a normal query: it stays undefined until ngAfterViewInit. A field initializer that reads it, or an ngOnInit that touches this.grid.someMethod(), hits undefined. Use { static: true } only when the element is unconditional and you accept a value that never changes.
Signal queries replace the timing puzzle with reactivity. viewChild(), viewChildren(), contentChild(), and contentChildren() return signals that reflect the current result, readable in a computed or effect without picking a hook; viewChild.required() drops undefined from the type and errors if nothing matches. That is the reason to prefer them: the query result becomes a value you subscribe to, so downstream derived state updates on its own instead of depending on when you read it.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
gapmap pro
You've read the Angular components and DI notes. An interviewer will ask you to prove them.
Know you're ready. Don't hope.
The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:
Mock interviews on your topics
An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.
Voice test interviews
The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.
A plan to your date
Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.
A mentor inside every lesson
Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.
Pro $20/mo · Pro+ $50/mo · every study note on the site stays free