Angular NotesRohit’s interview study guide
Chapter 06

Component Communication

Passing data down with input(), events up with output(), two-way with model(), and deriving state instead of watching it.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Parent to child with input()

Components form a tree (see Parent and child components), and data moves through it along the edges. The basic rule is data down, events up: a parent hands values to its child through inputs, and the child reports back through outputs. A child never reaches up and edits its parent's state, and a parent never pokes at a child's fields directly. That one-directional flow is what makes it possible to look at a template and know where a value comes from.

An input is a public property of the child that the parent can bind to with [name]="expression". In modern Angular you declare it with the input() function, which creates an InputSignal: a read-only signal whose value Angular sets from the parent's binding. You read it by calling it, name(), in the template or in TypeScript, and because it's a signal, anything derived from it (computed, the template) updates automatically when the parent sends a new value.

A mental model: an input is a one-way pipe from the parent's expression into a read-only signal in the child. The parent owns the tap. The child can drink from it but can't pour anything back up it.

user-card.ts
import { Component, Input, input, signal } from '@angular/core';

// Modern: signal inputs
@Component({
  selector: 'app-user-card',
  template: `<h3>{{ name() }}</h3><p class="role">{{ role() }}</p>`,
})
export class UserCard {
  name = input.required<string>();
  role = input('guest');
}

// Old: decorator inputs
@Component({
  selector: 'app-legacy-user-card',
  template: `<h3>{{ name }}</h3><p class="role">{{ role }}</p>`,
})
export class LegacyUserCard {
  @Input({ required: true }) name!: string;
  @Input() role = 'guest';
}

@Component({
  selector: 'app-team',
  imports: [UserCard, LegacyUserCard],
  template: `
    <app-user-card [name]="lead().name" [role]="lead().role" />
    <app-user-card name="Linus" />
    <app-legacy-user-card [name]="lead().name" />
  `,
})
export class Team {
  lead = signal({ name: 'Ada', role: 'admin' });
}
AdvancedSpec: inputs flowing from Team to UserCard
team.spec.ts
const f = TestBed.createComponent(Team);
await f.whenStable();
expect(all(el, 'h3')).toEqual(['Ada', 'Linus', 'Ada']);
expect(all(el, '.role')).toEqual(['admin', 'guest', 'guest']);

f.componentInstance.lead.set({ name: 'Grace', role: 'owner' });
await f.whenStable();
expect(all(el, 'h3')).toEqual(['Grace', 'Linus', 'Grace']);
expect(all(el, '.role')).toEqual(['owner', 'guest', 'guest']);
What’s happening
  1. name = input.required<string>() declares an input with no default that the parent must bind (next topic). role = input('guest') declares an optional input whose value is 'guest' until a parent binds something; its type, InputSignal<string>, is inferred from the default.
  2. Team must list UserCard in its imports, or <app-user-card> is an unknown element. When Angular creates Team's view, it creates one UserCard instance per <app-user-card> tag — two separate instances with separate inputs.
  3. On the first check Angular evaluates lead().name in the parent's context ('Ada') and writes it into the first child's name input. The second child uses name="Linus", a plain attribute: that's a static string input, set once. Its role isn't bound, so it keeps 'guest'.
  4. The child's template reads name() and role(). Reading the input signals is what ties the child's view to them.
  5. lead.set({ name: 'Grace', role: 'owner' }) changes the parent's signal. On the next check Angular re-evaluates the bindings lead().name and lead().role, sees new values, sets the children's inputs, and the children re-render: Grace / owner. The second card is untouched — Linus / guest — because nothing it's bound to changed.
  6. The legacy card does the same with @Input(): Angular assigns the field (this.name = 'Ada') instead of setting a signal, and the template reads the plain field {{ name }}. name! is there because TypeScript can't know Angular will assign it before use. It works, but nothing in TypeScript can react to the change except ngOnChanges or a setter — that's the main reason signal inputs replaced it.
AdvancedCompile error when writing an input

Inputs are read-only from the inside. This is deliberate — the parent owns the value — and the compiler enforces it:

TypeScript
export class StrictPage {
  page = input(1);
  rename() {
    this.page.set(2); // ❌ TS2339: Property 'set' does not exist on type 'InputSignal<number>'.
  }
}
AdvancedSetting inputs in tests with setInput

If the child needs to change the value and tell the parent, that's model(). If it needs a local copy that starts from the input but can then diverge (an editable draft), that's linkedSignal.

Strict templates also type-check the binding itself: <app-user-card [name]="42" /> fails with TS2322: Type 'number' is not assignable to type 'string'.

In tests you don't have a parent template, so you set inputs on the component ref. This is the API Angular wants you to use (it goes through the same path a template binding does):

user-card.spec.ts
const f = TestBed.createComponent(UserCard);
f.componentRef.setInput('name', 'Barbara');
await f.whenStable();
// <h3>Barbara</h3><p class="role">guest</p>
Advanced@Input() vs input() reference table
Old: @Input()Modern: input()
Declaration@Input() role = 'guest';role = input('guest');
Required@Input({ required: true }) name!: string;name = input.required<string>();
Readthis.rolethis.role()
React to changesngOnChanges or a settercomputed() / effect()
Writable inside the childYes (and that's a bug magnet)No — compile error
Transform@Input({ transform: numberAttribute })input(0, { transform: numberAttribute })
Rename@Input('alias') / { alias }input('', { alias: 'label' })

#Required inputs and input transforms

Some components make no sense without a particular input: a ProductPage without a product id, a UserCard without a name. input.required<T>() declares that, and it's checked twice:

  • At compile time, every template that uses the component must bind it.
  • At runtime, reading it before Angular has set it throws.
HTML
<app-user-card />
<!-- ❌ NG8008: Required input 'name' from component UserCard must be specified. -->
AdvancedSpec: a missing required input
required.spec.ts
// No setInput('name', ...) — simulate a missing value
const f = TestBed.createComponent(UserCard);
await expect(f.whenStable()).rejects.toThrow(
  'NG0950: Input "name" is required but no value is available yet.',
);
What’s happening
  1. The template compiler knows name is required, so the <app-user-card /> without [name] fails the build with NG8008. Templates can't forget it.
  2. Code that creates a component without a template — TestBed, ViewContainerRef.createComponent, the router — isn't checked at compile time. In the test, nothing sets name.
  3. On the first check the template calls name(). A required input has no default, so instead of returning undefined (which its type says is impossible), it throws NG0950: Input "name" is required but no value is available yet.
  4. Inputs aren't set until after the constructor, so reading one during construction can't work. Angular 22 catches the obvious case at compile time: seen = this.name(); as a field initialiser (or in the constructor) fails with NG8118: `name` is a required `input` and does not have a value in this context. If the read is hidden behind a method (seen = this.read();), the compiler can't see it and you get NG0950 at runtime instead. Read inputs in the template, in computed, or from ngOnInit onwards (see Constructor vs ngOnInit).
  5. The benefit: the type is plain string, not string | undefined, so you don't need ?. or ! everywhere.
AdvancedInput transforms and the string id bug

Input transforms

My notes have "Very important… hidden bug in my implementation" here. A transform is a function Angular runs on every incoming value before storing it in the input. It's the place to convert at the boundary: a string to a number, an attribute's presence to a boolean, null to a default.

The bug: the router hands you route parameters as strings. When you bind them to inputs (see Route parameters as component inputs), your number-typed input quietly receives "42". TypeScript can't catch it, because the router sets the input at runtime, by name, outside any template the compiler checks.

buggy-product.ts
export const PRODUCTS: Product[] = [
  { id: 7, name: 'Keyboard' },
  { id: 42, name: 'Monitor' },
];

// The bug: the type says number, the router sends "42".
@Component({
  selector: 'app-buggy-product',
  template: `
    <p>{{ product()?.name ?? 'Not found' }}</p>
    <p>next id: {{ id() + 1 }}</p>
    <p>{{ kind() }}</p>
  `,
})
export class BuggyProduct {
  id = input.required<number>();
  product = computed(() => PRODUCTS.find((p) => p.id === this.id()));
  kind = computed(() => typeof this.id());
}

// The fix: convert at the boundary.
@Component({ selector: 'app-product', template: `…same template…` })
export class ProductPage {
  id = input.required({ transform: numberAttribute });
  product = computed(() => PRODUCTS.find((p) => p.id === this.id()));
  kind = computed(() => typeof this.id());
}
product.spec.ts
// routes: { path: 'buggy/:id', component: BuggyProduct }, { path: 'product/:id', component: ProductPage }
// provideRouter(routes, withComponentInputBinding())
const harness = await RouterTestingHarness.create();

const buggy = await harness.navigateByUrl('/buggy/42', BuggyProduct);
expect(buggy.id()).toBe('42');
// rendered: "Not found", "next id: 421", "string"

const page = await harness.navigateByUrl('/product/42', ProductPage);
expect(page.id()).toBe(42);
// rendered: "Monitor", "next id: 43", "number"
What’s happening
  1. The URL /buggy/42 matches buggy/:id. Route parameters are parsed out of a URL, and a URL is text, so the router's params are always strings: { id: '42' }.
  2. withComponentInputBinding() makes the router call setInput('id', '42') on the routed component. input.required<number>() is only a type annotation; there's no runtime conversion, so the signal now holds the string '42'.
  3. PRODUCTS.find(p => p.id === this.id()) compares 42 === '42' → false for every product, so the page says Not found for a product that exists. That's the hidden bug: no error, no warning, just wrong behaviour.
  4. id() + 1 is string concatenation: '42' + 1 → '421'. typeof confirms 'string'. TypeScript believed it was a number the whole time.
  5. In ProductPage, transform: numberAttribute runs on every value the router sets: numberAttribute('42') → 42. Now find matches Monitor, id() + 1 is 43, and typeof is 'number'. The inferred type is InputSignalWithTransform<number, unknown>: the input accepts anything (unknown) and stores a number.
  6. The lesson generalises: anything that arrives as text — route params, query params, HTML attributes — should be converted where it enters the component, not wherever it's used.

The two built-in transforms, with their real behaviour in Angular 22:

transforms.ts
@Component({
  selector: 'app-pager',
  template: `<p>{{ page() }} of {{ total() }}, {{ compact() ? 'compact' : 'full' }}</p>`,
})
export class Pager {
  page = input.required({ transform: numberAttribute });
  total = input(1, { transform: (v: unknown) => numberAttribute(v, 1) });
  compact = input(false, { transform: booleanAttribute });
}

// <app-pager page="3" total="10" compact />   → "3 of 10, compact"
// <app-pager [page]="5" total="oops" />       → "5 of 1, full"

numberAttribute('42');       // 42
numberAttribute(' 42 ');     // 42
numberAttribute('1e3');      // 1000
numberAttribute('');         // NaN
numberAttribute('42abc');    // NaN
numberAttribute('abc', 0);   // 0  (the fallback)
booleanAttribute('');        // true   — <app-pager compact>
booleanAttribute('false');   // false
booleanAttribute('disabled');// true
booleanAttribute(null);      // false
booleanAttribute(0);         // true   (!)
What’s happening
  1. page="3" is a static attribute, so the value is the string '3'. Without the transform it would be a compile error — TS2322: Type 'string' is not assignable to type 'number' — because the input's declared type is number. With numberAttribute, the input accepts the string and stores 3.
  2. [page]="5" is a property binding with a real number. numberAttribute(5) returns 5 unchanged, so both binding styles work.
  3. total="oops" can't be parsed. Plain numberAttribute would give NaN; the wrapper passes a fallback as the second argument, so total becomes 1. The transform function must be pure and synchronous — it runs on every write.
  4. compact with no value is the attribute-presence pattern from HTML (<input disabled>): the value is '', and booleanAttribute('') is true. When the attribute is absent, the input keeps its default false.
  5. numberAttribute rejects partial numbers ('42abc' → NaN, unlike parseInt) and empty strings. booleanAttribute returns true for anything except false, 'false', null and undefined — which is why booleanAttribute(0) is true. It's meant for attribute strings, not numbers.
Why does the lookup fail?
TypeScript
// route: { path: 'orders/:orderId', component: OrderPage }, with withComponentInputBinding()
export class OrderPage {
  orderId = input<number>();
  order = computed(() => this.orders().find((o) => o.id === this.orderId()));
}
// Navigating to /orders/1001 shows "Order not found", but order 1001 exists.
Show answer

orderId() is the string '1001', so o.id === '1001' is false for the numeric id 1001.

What’s happening
  1. The router extracts orderId from the URL as the string '1001' and sets the input with it.
  2. input<number>() is a compile-time type only. Nothing converts the value, so the signal holds '1001'.
  3. Strict equality between a number and a string is always false, so find returns undefined and the template shows "Order not found".
  4. Fix: orderId = input(undefined, { transform: (v: unknown) => v == null ? undefined : numberAttribute(v) }), or input.required({ transform: numberAttribute }) if the route always has it.

#Deriving state with computed() instead of ngOnChanges

A component often shows something derived from its inputs: a total from an amount and a tax rate, a filtered list from items and a search term. With decorator inputs, the only hook that knew an input had changed was ngOnChanges, so the classic pattern was to recompute a field there. My notes put it as "ngOnChanges old way… now you can use computed()".

With signal inputs, the derived value is just a computed() over the input signals. Angular tracks which inputs it read and recomputes it lazily the next time something reads it after one of them changed. You describe what the value is, not when to update it.

price.ts
// Old: decorator inputs + ngOnChanges to keep a derived field in sync
@Component({ selector: 'app-legacy-price', template: `<p>{{ total }}</p>` })
export class LegacyPrice implements OnChanges {
  @Input() amount = 0;
  @Input() taxRate = 0;
  total = 0;

  ngOnChanges(changes: SimpleChanges): void {
    for (const [key, c] of Object.entries(changes)) {
      changeLog.push(`${key}: ${c.previousValue} -> ${c.currentValue} (first: ${c.firstChange})`);
    }
    this.total = this.amount * (1 + this.taxRate);
  }
}

// Modern: signal inputs + computed()
@Component({ selector: 'app-price', template: `<p>{{ total() }}</p>` })
export class Price {
  amount = input(0);
  taxRate = input(0);
  total = computed(() => this.amount() * (1 + this.taxRate()));
}
AdvancedSpec: ngOnChanges vs computed totals
price.spec.ts
const f = TestBed.createComponent(LegacyPrice);
f.componentRef.setInput('amount', 200);
f.componentRef.setInput('taxRate', 0.25);
await f.whenStable();              // <p>250</p>
f.componentRef.setInput('amount', 400);
await f.whenStable();              // <p>500</p>
expect(changeLog).toEqual([
  'amount: undefined -> 200 (first: true)',
  'taxRate: undefined -> 0.25 (first: true)',
  'amount: 200 -> 400 (first: false)',
]);
// Price (computed) renders the same 250, then 500.
What’s happening
  1. Legacy, first check. Both inputs were set before the check, so Angular calls ngOnChanges once with a SimpleChanges object holding both: amount and taxRate, each a SimpleChange with previousValue (undefined on the first change, not the field's default 0), currentValue and firstChange: true. The hook computes 200 × 1.25 = 250.
  2. Legacy, second change. Only amount changed, so changes contains only amount: 200 -> 400 (first: false). The hook recomputes from this.amount and this.taxRate → 500. Notice you must recompute from the fields, and remember to handle every input that affects total — forget taxRate in a condition and it goes stale.
  3. Modern. total is a computed that reads amount() and taxRate(). When the template reads total(), it calculates 250 and remembers that it depends on those two signals.
  4. Setting amount to 400 marks total as stale. The next read — the template during the check — recomputes 500. If nothing read total, nothing would compute.
  5. There's no hook, no SimpleChanges string keys (changes['amount'] isn't type-checked against the input names), no mutable total field that could be assigned elsewhere, and total works the same whether amount came from a parent, a test or the router.
AdvancedWhere ngOnChanges still fits

ngOnChanges still exists, and it fires for signal inputs too — a test with amount = input(0) logged ngOnChanges for each setInput. You'll still meet it in older code, and it's the right tool if you really need the previous value of several inputs together. For side effects in response to an input (logging, calling an imperative API), the signal equivalent is effect() — see effect() and when not to use it. For "derive a value", reach for computed. More on the hook in ngOnChanges and SimpleChanges.

AdvancedTracking previous input values

#Child to parent with output()

Data goes down; events go up. A child declares an output with output<T>(), and calls .emit(value) when something happens. The parent listens with event-binding syntax, (name)="handler($event)", where $event is the emitted value. It's the same syntax as a DOM event like (click), and that resemblance is useful and misleading in equal measure (see the next topic).

The child doesn't know or care what the parent does with the event. It says "the user toggled todo 2"; the parent decides that means updating its list. That keeps the child reusable, and keeps the state in one place — the parent that owns it.

todo-item.ts
// Modern: output()
@Component({
  selector: 'app-todo-item',
  template: `
    <span>{{ todo().title }}</span>
    <button class="toggle" (click)="onToggle()">toggle</button>
  `,
})
export class TodoItem {
  todo = input.required<Todo>();
  toggled = output<number>();

  onToggle() {
    order.push('child: before emit');
    this.toggled.emit(this.todo().id);
    order.push('child: after emit');
  }
}

// Old: @Output() + EventEmitter
@Component({
  selector: 'app-legacy-todo-item',
  template: `<button class="toggle" (click)="toggled.emit(todo.id)">toggle</button>`,
})
export class LegacyTodoItem {
  @Output() toggled = new EventEmitter<number>();
  todo: Todo = { id: 99, title: 'legacy', done: false };
}

@Component({
  selector: 'app-todo-list',
  imports: [TodoItem, LegacyTodoItem],
  template: `
    @for (t of todos(); track t.id) {
      <app-todo-item [todo]="t" (toggled)="toggle($event)" />
    }
    <app-legacy-todo-item (toggled)="lastLegacy = $event" />
    <p id="done">{{ doneCount() }} done</p>
  `,
})
export class TodoList {
  todos = signal<Todo[]>([
    { id: 1, title: 'Write chapter', done: false },
    { id: 2, title: 'Run tests', done: false },
  ]);
  lastLegacy: number | null = null;
  doneCount = () => this.todos().filter((t) => t.done).length;

  toggle(id: number) {
    order.push(`parent: toggle(${id})`);
    this.todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  }
}
AdvancedSpec: synchronous emit order
todo-list.spec.ts
(el.querySelectorAll('app-todo-item .toggle')[1] as HTMLElement).click();
order.push('after click() returned');
expect(order).toEqual(['child: before emit', 'parent: toggle(2)', 'child: after emit', 'after click() returned']);
expect(text(el, '#done')).toBe('0 done');   // DOM not refreshed yet
await f.whenStable();
expect(text(el, '#done')).toBe('1 done');
What’s happening
  1. The parent's template binds (toggled)="toggle($event)" on each <app-todo-item>. Angular compiles that into a subscription to that child instance's toggled output, created along with the child.
  2. Clicking the second item's button runs the child's onToggle(). It logs, then calls this.toggled.emit(2).
  3. emit is synchronous: it calls every listener right there, so the parent's toggle(2) runs inside emit — the log order is before emit, parent: toggle(2), after emit. There's no queue and no bubbling; it's a direct function call from child to parent.
  4. toggle replaces the list immutably with update, setting todo 2 to done. The signal change marks the view for checking, but the DOM still says 0 done until change detection runs; after whenStable() it says 1 done.
  5. The legacy item works the same way from the parent's side: (toggled) subscribes to the EventEmitter, $event is 99. EventEmitter extends RxJS Subject, so it drags RxJS concepts into a simple callback. output() returns an OutputEmitterRef<T>, which has just emit() and subscribe() — no pipe, no next, no error.
  6. An output is cleaned up with its component. Emitting after the component is destroyed doesn't throw; Angular 22 logs NG0953: Unexpected emit for destroyed `OutputRef`. The owning directive/component is destroyed. (verified in a test).
AdvancedOutput typing, naming and RxJS interop

A few practical details:

  • Typing: output<number>() makes emit require a number and $event a number in the parent. output() with no type argument is output<void>(); call emit() with nothing.
  • Naming: name outputs after what happened (toggled, selected, deleted), not onToggle. Don't reuse DOM event names like click or change for outputs — the parent's (click) then listens to both your output and the native click bubbling out of the component. A test with an output named click emitted from an inner button counted two handler calls for one click.
  • Programmatic use: componentRef.instance.toggled.subscribe(id => …) works when you create components dynamically or in tests; it returns an OutputRefSubscription with unsubscribe().
  • From RxJS: outputFromObservable(stream$) (from @angular/core/rxjs-interop) turns an observable into an output, and outputToObservable(output) goes the other way.

#Component events don't bubble

My notes: "Important — unlike DOM, here events don't bubble." A DOM event like click travels up the element tree: a listener on any ancestor hears it. A component output does not. emit() calls the listeners bound on that component's own host element in its direct parent's template, and nothing else. A grandparent can't listen for a grandchild's output, no matter how you nest them.

The trap is that a grandparent template like <app-card (deleted)="…"> compiles without error even when Card has no deleted output. Angular treats any unknown (name) as a native DOM event listener on the host element — a perfectly valid thing to do — so it just waits for a DOM event called deleted that never comes.

board.ts
@Component({
  selector: 'app-delete-button',
  template: `<button (click)="onClick()">delete</button>`,
})
export class DeleteButton {
  private host = inject(ElementRef<HTMLElement>);
  deleted = output<void>();

  onClick() {
    this.deleted.emit(); // Angular output: only the direct parent's (deleted) binding hears this
    this.host.nativeElement.dispatchEvent(new CustomEvent('removed', { bubbles: true })); // real DOM event
  }
}

@Component({
  selector: 'app-card',
  imports: [DeleteButton],
  template: `<app-delete-button (deleted)="onDeleted()" />`,
})
export class Card {
  onDeleted() {
    heard.push('card heard deleted');
  }
}

@Component({
  selector: 'app-board',
  imports: [Card],
  template: `
    <app-card
      (deleted)="heard.push('board heard deleted')"
      (removed)="heard.push('board heard DOM removed')"
    />
  `,
})
export class Board {
  heard = heard;
}
AdvancedSpec: outputs vs bubbling DOM events
board.spec.ts
const f = TestBed.createComponent(Board);
await f.whenStable();
(f.nativeElement.querySelector('button') as HTMLElement).click();
expect(heard).toEqual(['card heard deleted', 'board heard DOM removed']);
What’s happening
  1. The tree is Board → Card → DeleteButton. Clicking the button runs DeleteButton.onClick().
  2. this.deleted.emit() calls the listeners subscribed to this DeleteButton instance's deleted output. Only one exists: the (deleted)="onDeleted()" binding in Card's template. Log: card heard deleted.
  3. Board's template has (deleted) on <app-card>, but Card declares no deleted output. The compiler accepted it (ng build passes) and set it up as a DOM addEventListener('deleted', …) on the <app-card> element. Angular outputs don't dispatch DOM events, so it never fires: there's no board heard deleted.
  4. For contrast, the button also dispatches a real DOM CustomEvent('removed', { bubbles: true }) from its host element. That travels up the DOM, through <app-card>, where Board's (removed) listener hears it: board heard DOM removed.
  5. So the observable difference: outputs are direct parent-child callbacks; DOM events bubble through the element tree. The same (name) syntax listens for both, which is why the mistake is silent.
AdvancedRelaying an event through each level

How to get an event to the grandparent:

relay.ts
// Re-emit at each level
@Component({
  selector: 'app-relay-card',
  imports: [DeleteButton],
  template: `<app-delete-button (deleted)="deleted.emit()" />`,
})
export class RelayCard {
  deleted = output<void>();
}

@Component({
  selector: 'app-relay-board',
  imports: [RelayCard],
  template: `<app-relay-card (deleted)="heard.push('relay board heard deleted')" />`,
})
export class RelayBoard {
  heard = heard;
}
// clicking the button logs: ['relay board heard deleted']
What’s happening
  1. RelayCard declares its own deleted output and forwards the child's event with (deleted)="deleted.emit()".
  2. Now <app-relay-card (deleted)> in the board binds to a real output, so the board's handler runs: relay board heard deleted.
  3. Each level is explicit about what it exposes. That's verbose for deep trees — which is the signal to stop passing events up.
AdvancedOptions for reaching distant ancestors

Options, in order of preference:

  • Re-emit through one or two levels (above) — explicit and type-checked.
  • A shared service with signals for state that many components touch (a cart, a selection) — components call store.remove(id) and read store.items(); no events travel through the tree at all. See Sharing state with signal-based services.
  • A DOM CustomEvent with bubbles: true — works, but it's untyped, invisible to the template type checker, and couples you to DOM structure. Rarely the right choice in Angular.

#Two-way binding with model()

Sometimes a value really is shared: a rating widget, a toggle, a date picker. The parent owns the value, but the child edits it, and both should stay in sync. That's two-way binding, written with the "banana in a box" syntax [(value)]="score" (see Two-way binding).

[(value)]="score" is sugar for two bindings: [value]="score" (an input down) and (valueChange)="score = $event" (an event up). The child must therefore expose an input called value and an output called valueChange. My notes called model() "really cool" because it declares both halves in one line: value = model(0) is a writable signal that is also an input named value and an output named valueChange. When the child calls value.set(4), the local signal changes and valueChange emits 4.

rating.ts
// Modern: model()
@Component({
  selector: 'app-rating',
  template: `
    @for (star of stars(); track star) {
      <button [class.on]="star <= value()" (click)="value.set(star)">{{ star }}</button>
    }
  `,
})
export class Rating {
  value = model(0);
  max = input(5);
  stars = () => Array.from({ length: this.max() }, (_, i) => i + 1);
}

// Old: the @Input() x + @Output() xChange pair that [(x)] desugars to
@Component({
  selector: 'app-legacy-rating',
  template: `<button (click)="pick(4)">4</button>`,
})
export class LegacyRating {
  @Input() value = 0;
  @Output() valueChange = new EventEmitter<number>();
  pick(n: number) {
    this.value = n;
    this.valueChange.emit(n);
  }
}

@Component({
  selector: 'app-review',
  imports: [Rating, LegacyRating],
  template: `
    <app-rating id="two-way" [(value)]="score" />
    <app-legacy-rating [(value)]="legacyScore" />
    <p id="score">{{ score() }}</p>
  `,
})
export class Review {
  score = signal(3);
  legacyScore = 0; // a plain property works with [(x)] too
}
AdvancedSpec: two-way model star rating
rating.spec.ts
// three of the five stars start "on"
star('two-way', 5);
await f.whenStable();
expect(c.score()).toBe(5);
expect(text(el, '#score')).toBe('5');

(el.querySelector('app-legacy-rating button') as HTMLElement).click();
expect(c.legacyScore).toBe(4);
What’s happening
  1. [(value)]="score" — note it's score, not score(). For a signal, Angular binds the signal itself: it reads score() for the input side and calls score.set($event) for the output side. Writing [(value)]="score()" is a compile error: NG5002: Unsupported expression in a two-way binding, because you can't assign to a function call.
  2. First check: the child's value model receives 3 from score, and three stars render with the on class.
  3. Clicking star 5 runs value.set(5) in the child. A model is writable, so the child's signal becomes 5, and the model also emits valueChange with 5.
  4. The parent's half of the binding receives it and runs score.set(5). After the check, the parent's <p> shows 5, and the child's input side, now 5 from the parent, agrees with its own local value.
  5. The legacy pair does the same thing by hand: @Input() value plus @Output() valueChange, and the child must both assign this.value and emit. [(value)]="legacyScore" works because the names follow the x / xChange convention; with a plain property, Angular assigns legacyScore = $event → 4.
Advancedmodel.required and aliased models

model mirrors input: model.required<T>() for a required model, model(0, { alias: 'rating' }) to rename it (the output becomes ratingChange). Two-way binding is also how [(ngModel)] works on form controls — it's just ngModel plus ngModelChange (see Template-driven forms with ngModel).

AdvancedWhen valueChange emits

#input() vs model()

My notes say "Input (always keep getting confused)" — so here is the rule as plainly as possible. input() is read-only in the child; model() is writable in the child and reports its writes upward. Ownership is the question to ask: if the parent owns the value and the child only displays it, it's an input. If the child is a control whose job is to change the parent's value, it's a model.

What makes it confusing is that a model can be bound three ways, and each behaves differently. The test binds the same Rating component three times:

review.html
<app-rating id="two-way" [(value)]="score" />
<app-rating id="one-way" [value]="fixed()" />
<app-rating id="listen"  [value]="watched()" (valueChange)="onChange($event)" />
AdvancedSpec: one-way vs two-way model binding
review.spec.ts
// score = signal(3), fixed = signal(2), watched = signal(1)
star('one-way', 4);
await f.whenStable();
expect(c.fixed()).toBe(2);      // parent untouched
expect(on('one-way')).toBe(4);  // child kept its local write

c.fixed.set(1);
await f.whenStable();
expect(on('one-way')).toBe(1);  // parent's next value wins

star('listen', 3);
await f.whenStable();
expect(c.changes).toEqual([3]); // onChange received 3 and set watched
What’s happening
  1. Two-way ([(value)]="score") — covered above: child writes go to the parent's signal, parent writes come down. One source of truth.
  2. One-way into a model ([value]="fixed()"): clicking star 4 sets the child's model to 4, so it shows four stars, but nothing is listening to valueChange, so the parent's fixed is still 2. The two have silently diverged.
  3. Then the parent sets fixed to 1. The input binding changed, so Angular pushes 1 into the model and overwrites the child's local 4. The parent's value always wins on its next change.
  4. Listen explicitly ([value] + (valueChange)): the long form of two-way binding. onChange(3) runs and the parent decides what to do — here it records it and sets watched, but it could validate, clamp or ignore it. Use this when the parent needs to intercept the change.
  5. With a plain input() in place of model(), case 2's click would be impossible: value.set doesn't exist (TS2339). That's the safety input gives you.
Advancedinput() vs model() reference table
input()model()
TypeInputSignal<T> (read-only)ModelSignal<T> (writable signal + input + output)
Child can write itNo — set doesn't existYes — set / update
Creates an outputNoYes, <name>Change, emitted on child writes
Parent binding[x]="value"[(x)]="sig", or [x] and/or (xChange)
transform optionYesNo (ModelOptions has only alias and debugName)
Use forData the child displays or derives fromValues the child edits: form-like controls, toggles, pickers
One-way into a model
HTML
<!-- parent: theme = signal('light') -->
<app-theme-toggle [mode]="theme()" />
<p>{{ theme() }}</p>
<!-- ThemeToggle: mode = model('light'); its button calls mode.set('dark') -->

The user clicks the toggle. What does the parent's <p> show, and what does the toggle show?

Show answer

The <p> still shows light; the toggle shows dark.

What’s happening
  1. [mode]="theme()" is a one-way input binding into the model: the parent sends 'light' down.
  2. The click calls mode.set('dark') inside the child. A model is writable, so the toggle's own value becomes 'dark' and it renders that.
  3. The model emits modeChange('dark'), but the parent isn't listening — there's no (modeChange) and no [(mode)] — so theme stays 'light'.
  4. They're now out of sync until the parent changes theme, at which point its value overwrites the toggle's. Fix: [(mode)]="theme" (the signal, not theme()).

#Route parameters as component inputs

The router can fill a routed component's inputs for you. With withComponentInputBinding() in provideRouter, every path parameter, query parameter, static route data key and resolver result is bound to the input with the same name. Instead of injecting ActivatedRoute and reading snapshot.paramMap or subscribing to paramMap, the component just declares id = input(...). My notes cover the older ActivatedRoute styles in Route parameters.

app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      [{ path: 'product/:id', component: ProductPage, data: { title: 'Product details' } }],
      withComponentInputBinding(),
    ),
  ],
};
AdvancedRouted component with bound inputs
product-page.ts
@Component({
  selector: 'app-product',
  template: `
    <p>{{ product()?.name ?? 'Not found' }}</p>
    <p>next id: {{ id() + 1 }}</p>
    <p>{{ kind() }}</p>
    <p>{{ tab() ?? 'overview' }}</p>
    <p>{{ title() }}</p>
  `,
})
export class ProductPage {
  id = input.required({ transform: numberAttribute });
  tab = input<string>(); // from ?tab=...
  title = input(''); // from route data
  product = computed(() => PRODUCTS.find((p) => p.id === this.id()));
  kind = computed(() => typeof this.id());
}
AdvancedSpec: route inputs and component reuse
product-page.spec.ts
const harness = await RouterTestingHarness.create();

const page = await harness.navigateByUrl('/product/42?tab=reviews', ProductPage);
// rendered: "Monitor", "next id: 43", "number", "reviews", "Product details"

const again = await harness.navigateByUrl('/product/7', ProductPage);
expect(again).toBe(page); // same instance, new input value
// rendered: "Keyboard", "next id: 8", "number", "overview", "Product details"
What’s happening
  1. Navigating to /product/42?tab=reviews matches product/:id. The router collects { id: '42' } from the path, { tab: 'reviews' } from the query string and { title: 'Product details' } from the route's data, and sets each as an input by name.
  2. id goes through numberAttribute, so it's 42 and the computed finds Monitor — this is the fix from Required inputs and input transforms. tab is the string 'reviews'. title comes from static route data.
  3. Navigating to /product/7 uses the same route config, so the router reuses the component instance (the test checks again === page). Only the inputs change: id becomes 7, and because ?tab is gone, tab is set to undefined (the template falls back to overview).
  4. That reuse is exactly why the old this.route.snapshot.paramMap.get('id') in ngOnInit was a bug: ngOnInit doesn't run again, so the snapshot stays on 42. Inputs are signals, so product recomputes automatically.
  5. If several sources use the same key, the documented precedence is lowest to highest: query params, then path and matrix params, then static data, then resolver data — a resolver wins.
AdvancedEdge cases: unmatched and child-route inputs

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.