Angular PrimerAngular 22 · 2022 → 2026

The component contract

How data gets in, how events get out, and why `ngOnChanges` is no longer part of the answer.

Inputs are signals

An input used to be a decorated field that the framework wrote into at the right moment, which is why you needed ngOnChanges and a SimpleChanges object to react to it. An input is now a signal you read:

2026 — signal inputs
import { Component, input } from '@angular/core';

@Component({ selector: 'app-badge', template: `<span>{{ label() }}</span>` })
export class Badge {
  readonly label = input.required<string>();       // must be provided by the parent
  readonly tone = input<'info' | 'warn'>('info');  // optional, with a default
}
  • input<T>() gives you InputSignal<T | undefined> unless you pass a default.
  • input.required<T>() gives you InputSignal<T> and fails at compile time if the parent forgets it. That is almost always what you want: a required input should be a build error, not a runtime surprise.
  • The value is read with label(), so an input behaves like any other signal in a computed, an effect or a template.

2022 — decorator, and ngOnChanges to react

@Input() label!: string; @Input() tone: 'info' | 'warn' = 'info'; private labelUpper = ''; ngOnChanges(changes: SimpleChanges) { if (changes['label']) { this.labelUpper = this.label.toUpperCase(); } }

2026 — signal input, and computed

readonly label = input.required<string>(); readonly labelUpper = computed(() => this.label().toUpperCase());

The second column is not just shorter. The derived value is correct from the very first render, because a computed reads the input when it is read — there is no window between "input set" and "lifecycle hook ran" in which the template sees a stale value.

Outputs and two-way binding

An output is still an event emitter, now with its own function:

2026 — outputs
import { Component, output, model } from '@angular/core';

@Component({ selector: 'app-pager', template: `...` })
export class Pager {
  readonly changed = output<number>();      // parent binds (changed)="..."
  readonly page = model(1);                 // parent binds [(page)]="currentPage"

  next() {
    this.page.update((page) => page + 1);   // writes the model signal...
    this.changed.emit(this.page());         // ...and tells whoever cares
  }
}

model() is a writable signal owned by the child, bound two-way by the parent. Before v17 you had @Input() value plus @Output() valueChange and a naming convention the compiler could not check; a typo in either half failed silently. Now it is one declaration that both directions understand:

2026 — the parent
<app-pager [(page)]="currentPage" (changed)="onPage($event)" />

Use output() for "something happened" and model() for "a value the parent and child both own". Mixing them up is what produces the classic two-way binding loop.

input.required, model, output

One child component with a required input, a two-way model and an output. Press the child button and the parent sees the value change; press the parent button and the change flows back down.

parent: count = 0

Quantity = 0

(bumped) events: no events yet

Queries are signals too

@ViewChild came with AfterViewInit, a non-null assertion, and a setter if you wanted to react to it. A signal query is a signal:

2026 — view and content queries
readonly inputEl = viewChild.required<ElementRef<HTMLInputElement>>('search');
readonly panels = viewChildren(Panel);
readonly projectedHeader = contentChild(Header);

ngOnInit() {
  // safe: the query resolves before ngOnInit for static children
  this.inputEl().nativeElement.focus();
}

viewChild reads it once, viewChildren gives you a signal of the array, and contentChild looks into projected content. Pair them with afterNextRender or afterRenderEffect when the work needs the real DOM.

What is left of the lifecycle

The hooks you used are still there, and they still mean what they meant. What has gone is the need for two of them:

  • ngOnInit — still the right place for one-off setup that needs inputs.
  • ngOnDestroy — still right for manual teardown; with signals, most teardown simply disappears.
  • ngOnChanges — no longer needed for reacting to inputs. Use computed(); use effect() only if the reaction is a side effect rather than a value.
  • ngAfterViewInit — mostly replaced by afterNextRender, which does not run during prerendering and does not need a hook on every component that touches the DOM.
2026 — DOM work without a lifecycle hook
export class Chart {
  constructor() {
    afterNextRender(() => {
      this.renderInto(this.canvas().nativeElement);   // browser only
    });
  }
}

For DOM reading that must produce a value, afterRenderEffect is the v22 tool: it is an effect that runs after rendering, and it returns a signal instead of writing into a field.

Two traps that the decorator era hid and the signal era does not. First, never derive state in ngOnChanges — it runs after the template has already been checked once, which is where the "one frame behind" bugs came from; use computed. Second, do not read an input in the constructor and assume it is set: with signal inputs the value is there when it is read in a computed or a hook, not necessarily when the field initialiser runs.

Why the contract matters

Everything in this page is one idea: the data a component receives and sends is observable from its own code, with no lifecycle bookkeeping in between. Once inputs and queries are signals, a component can compute everything it needs in the same declarative style you use for local state — which is the subject of Signals, and the reason templates can be plain markup again in Templates & control flow.