#Components and templates questions
These questions check whether you can say what a template actually does: which binding writes a string and which writes a value, what control flow compiles to, how @for decides which row is which, when a pipe re-runs, and how data and events cross component boundaries. Answer each one out loud before you open it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning you should be able to give when they ask "why?". Every output quoted here comes from the Angular 22 specs behind chapters 3 to 6.
<button disabled="{{ locked }}">A</button>
<button [disabled]="locked">B</button>locked is false. And what happens if you pass an object to a child input with user="{{ me }}"?
Show answer
Button A is disabled and button B is enabled. Interpolation always produces a string, so A gets button.disabled = 'false', and a non-empty string is truthy. user="{{ me }}" doesn't compile: strict templates report TS2322: Type 'string' is not assignable to type 'User'.
disabled="{{ locked }}"compiles toɵɵproperty("disabled", ɵɵinterpolate(ctx.locked)). The interpolation turnsfalseinto the string'false'before it's assigned.- The browser coerces the string for the boolean
disabledproperty: any non-empty string istrue. The test showeddisabled: truewhilelockedwasfalse. [disabled]="locked"compiles toɵɵproperty("disabled", ctx.locked): the real booleanfalseis assigned and B is enabled.- The compiler didn't catch button A, because Angular doesn't type-check bindings to native DOM properties. It does type-check component inputs, which is why
user="{{ me }}"fails: interpolation can only ever produce a string. - The rule to say out loud: interpolation for text, property binding for everything else — booleans, numbers, objects and every component input.
Learn more: Interpolation vs property binding and Property binding.
<td [colspan]="span()"> fail to compile, and how do you make an ARIA attribute conditional?Show answer
Because colspan is only the attribute name. Property binding needs a DOM property, and the property is spelled colSpan, so the build fails with NG8002: Can't bind to 'colspan' since it isn't a known property of 'td'. Use [colSpan] or [attr.colspan]. To make an attribute conditional, bind null: [attr.aria-label]="label()" removes the attribute when label() is null.
[x]means "set the DOM propertyx". For a native element the compiler checks that the property exists, andHTMLTableCellElementhascolSpan(camel case), notcolspan.[colSpan]="span()"compiles toɵɵdomProperty("colSpan", …)and the browser reflects it to thecolspanattribute.[attr.colspan]="span()"compiles toɵɵattribute("colspan", ctx.span())—setAttribute('colspan', '2'). Attributes are always strings:[attr.data-id]="7"writes"7".- For ARIA,
data-*,roleand SVG attributes there's no property at all, so[attr.x]is the tool. Bindingnullorundefinedremoves the attribute; the test's button became<button data-id="7">X</button>afterlabel.set(null). - Angular 22 also maps
[aria-label](noattr.prefix) to an attribute for you, but[attr.aria-label]is the explicit form that works everywhere.
Learn more: Attribute binding.
<p>{{ count }}</p> show when count = signal(5)?Show answer
Not 5. In a development build it shows [Signal (count): 5] — the signal function stringified — and the build warns NG8109: count is a function and should be invoked: count(). Worse, the view never subscribed to the signal, so later count.set(6) doesn't re-render it. Always call it: {{ count() }}.
- A signal is a getter function.
{{ count }}interpolates the function object itself, not its value. - The compiler notices and warns twice:
NG8109andNG8117: Function in text interpolation should be invoked: count(). The build still succeeds, which is how this slips through. - Calling
count()does two jobs: it returns the value and registers the view as a consumer of the signal. That registration is how a zoneless, OnPush-by-default app knows to re-render exactly this view when the signal changes. - So the uncalled version is wrong twice: wrong text now, and no reactivity later.
- Calling a signal in a template isn't the "method call in a template" anti-pattern: a signal read just returns a stored value.
Learn more: Binding to component properties and signals and Signals vs plain properties.
Show answer
Both create and destroy DOM rather than hiding it. *ngIf is a structural directive: the * desugars to an <ng-template> and NgIf stamps it out, so it must be imported, needs ng-template refs for else, and creates a directive instance per use. @if is built-in syntax compiled to one ɵɵconditional instruction: no import, real @else if/@else, and it's what Angular 22 recommends (NgIf is deprecated since v20). Forget NgIf in a standalone component and the build only warns (NG8103); at runtime the paragraph never renders.
<p *ngIf="user; else signIn">is rewritten to<ng-template [ngIf]="user" [ngIfElse]="signIn"><p>…</p></ng-template>. The conditional logic lives in theNgIfclass; the compiler only sets its inputs.@if (loading()) {…} @else if (user(); as u) {…} @else {…}compiles to a singleɵɵconditional(ctx.loading() ? 0 : (tmp_0_0 = ctx.user()) ? 1 : 2, tmp_0_0). Angular computes a branch index and only touches the DOM when the index changes.- Without
NgIf(orCommonModule) inimports,<p *ngIf="true">builds withNG8103: The *ngIf directive was used in the template, but neither the NgIf directive nor the CommonModule was imported.At runtime the console logsNG0303: Can't bind to 'ngIf'…and the DOM is just an anchor comment: the*created a template and nothing rendered it. @ifneeds no wrapper element and no import, and theng generate @angular/core:control-flowschematic migrates*ngIffor you.
Learn more: @if vs *ngIf and *ngIf and ng-template.
@if (user()) { {{ user().name }} } fail strict template checking?user = signal<User | null>(null).
Show answer
Because checking user() doesn't narrow a second call to user(). The type checker reports TS2531: Object is possibly 'null'. Read it once: @if (user(); as u) { {{ u.name }} }, or use @let.
- The template type checker turns the block into TypeScript roughly like
if (ctx.user()) { ctx.user().name }. - TypeScript can't assume a function returns the same value twice, so the second
user()is stillUser | null. @if (user(); as u)stores the condition's value in a template variableu. Inside the blockuisUser, sou.namecompiles. The compiled code also readsuser()only once (tmp_0_0).- With a plain field,
@if (user) { {{ user.name }} }does narrow, like anifin TypeScript. The trap is specific to signals and other function calls. - The same idea explains why
@switchwith@default never;needs@let s = status();first.
Learn more: @if and @else and @switch.
@Component({
selector: 'app-todo-row',
template: `
<span>{{ title() }}</span>
<button (click)="editing.set(true)">edit</button>
@if (editing()) { <em>editing</em> }
<input placeholder="note" />
`,
})
export class TodoRow {
title = input.required<string>();
editing = signal(false);
}
// parent template:
// @for (todo of todos(); track $index) { <app-todo-row [title]="todo.title" /> }
// todos: Buy milk, Walk dog, Write notesThe user clicks "edit" on Buy milk and types for Rohit in its note. Then the parent runs todos.update(list => list.slice(1)). What's on screen?
Show answer
Walk dog [editing] note="for Rohit", then Write notes. Walk dog now claims to be in edit mode and shows Buy milk's note, because Angular kept rows 0 and 1, re-bound their titles, and destroyed the last row. With track todo.id the Buy milk row is the one destroyed and no state moves.
editinglives in eachTodoRowinstance and the note lives in each<input>DOM node. Neither comes from the item, so they stay wherever the row stays.- Before the delete the keys are
[0, 1, 2]; after it[0, 1]. Withtrack $indexthe key is the position, so keys0and1"still exist". - Angular keeps rows 0 and 1 and just sets their
titleinputs to'Walk dog'and'Write notes'. Key2is gone, so it destroys the third row. The test confirmed: DOM rows[true, true, false]survived. - Row 0 still has
editing = trueand the typed note, but now displays Walk dog. That's the bug. - With
track todo.id, keys go[1, 2, 3] → [2, 3]; key1is gone, so Buy milk's row — instance, signal, input and all — is destroyed, and the other two rows aren't touched. It's also less work: one removal instead of re-binding every row.
Learn more: Why not track $index and @for and track.
@Pipe({ name: 'pending' }) // pure (the default)
export class PendingPipe { transform(todos: Todo[]) { calls.pure++; return todos.filter((t) => !t.done); } }
@Pipe({ name: 'pendingImpure', pure: false })
export class PendingImpurePipe { transform(todos: Todo[]) { calls.impure++; return todos.filter((t) => !t.done); } }
// template:
// <p>{{ (todos | pending).length }} pending (pure)</p>
// <p>{{ (todos | pendingImpure).length }} pending (impure)</p>
// <button (click)="clicks.set(clicks() + 1)">other</button>
// <button (click)="addByPush()">push</button> addByPush() { this.todos.push({ title: 'Write tests', done: false }); }
// todos starts with one undone and one done itemShow answer
After "push" the page shows 1 pending (pure) next to 2 pending (impure), and the counts are { pure: 1, impure: 3 }. The pure pipe is stale: Angular compared todos === lastTodos, got true, and reused its cached result. The fix is an immutable update (todos = [...todos, item]), not pure: false.
- First render: both pipes run once and show
1. The pure pipe stores the input it saw — the array reference. Counts{ pure: 1, impure: 1 }. - "other": the click marks the view for checking, so the template runs again. The pure pipe sees the same reference and doesn't call
transform; the impure one runs anyway. Counts{ pure: 1, impure: 2 }. - "push": the item goes into the same array. On the next check the pure pipe's reference check still says "unchanged", so it shows the cached
1. The impure pipe recomputes and shows2. Counts{ pure: 1, impure: 3 }. - Replacing the array (
[...this.todos, item]) made the pure pipe run again in the chapter's test and show3— the pushed item was in there all along. - The impure pipe was "right" every time, but it ran on every check, including the one where nothing changed. Impure is for pipes whose output depends on something other than the input reference (
async, a "time ago" pipe).
Learn more: Pure and impure pipes.
<p>{{ totalMethod() }}</p>
<p>{{ lines | cartTotal }}</p>
<p>{{ total() }}</p> <!-- total = computed(...) over linesSig -->
<button (click)="likes.set(likes() + 1)">like</button>Show answer
{ method: 4, pipe: 1, computed: 1 }. The method runs on every check of the view; the pure pipe only when its input reference changes; the computed only when a signal it read changes. All three show the same total — the method just recomputes it for nothing.
- First render: all three bindings need a value, so each runs once:
{ method: 1, pipe: 1, computed: 1 }. - Each "like" click changes
likes, which has nothing to do with the cart, but the click and the signal mark this view for checking. Angular re-evaluates every binding three times. {{ totalMethod() }}is a plain function call in the compiled update block. Angular can't know its result hasn't changed without calling it, so it runs three more times:1 → 4.{{ lines | cartTotal }}compareslineswith the stored reference — same array — and reuses the result. Still1.{{ total() }}returns the computed's cached value becauselinesSigdidn't change. Still1.- Today's answer:
computed()for component-specific derived state, pure pipes for reusable formatting, methods only for trivial getters. In a zone.js app with the oldDefault(nowEager) strategy, the method count would multiply across every event in the app.
Learn more: Pipes vs method calls and Avoid heavy work in templates.
@Component({
imports: [AsyncPipe],
template: `
<p>{{ (load() | async)?.name }}</p>
<button (click)="likes.set(likes() + 1)">like</button>
`,
})
export class Fresh {
likes = signal(0);
load() {
return this.store.cold$.pipe(map((u) => u)); // a new Observable on every call
}
}Show answer
It resubscribes on every check. The async pipe sees a different observable reference each time, unsubscribes from the old one and subscribes to the new one: the test counted 1 subscription after the first render and 4 after three unrelated clicks. With an HTTP call that's one request per check, and it can loop. Create the observable once, as a field, or use toSignal/httpResource.
- The async pipe is impure, so
transformruns on every check. Each check callsload(), andpipe(map(...))returns a brand-newObservableobject. - Inside the pipe,
obj !== this._objmeans "a different observable": it calls_dispose()(unsubscribe, reset tonull) and subscribes to the new one. - This example "works" only because the source emits synchronously. With
http.get, each new subscription starts atnull; when the response arrives the pipe callsmarkForCheck(), which triggers a check, which callsload()again — another request, every time. - Related trap: every
| asyncusage is its own subscription, so three(user$ | async)on one coldhttp.getfield means three requests. Subscribe once with@if (user$ | async; as user)or@let.
Learn more: The async pipe and Multiple subscriptions mean multiple executions.
Rating has value = model(0), and each star button calls value.set(star). The parent has score = signal(3) and fixed = signal(2):
<app-rating id="two-way" [(value)]="score" />
<app-rating id="one-way" [value]="fixed()" />The user clicks star 5 on the first and star 4 on the second. Then the parent runs fixed.set(1). What are score(), fixed() and the second rating's stars at each step? And what if you write [(value)]="score()"?
Show answer
After the clicks: score() is 5, but fixed() is still 2 while the second rating shows 4 stars — the two have silently diverged. After fixed.set(1) the second rating shows 1: the parent's next value overwrites the child's local write. [(value)]="score()" doesn't compile: NG5002: Unsupported expression in a two-way binding.
model()is a writable signal that is also an input namedvalueand an output namedvalueChange.value.set(5)inside the child changes the local value and emitsvalueChange(5).[(value)]="score"is[value]="score()"plus(valueChange)="score.set($event)", so the parent'sscorebecomes5.[value]="fixed()"is one-way. The child's model accepts the write (4), emitsvalueChange(4), but nobody listens, sofixedstays2.fixed.set(1)changes the input binding, so Angular pushes1into the model and overwrites the child's4. The parent always wins on its next change.- Two-way binding needs something assignable: the writable signal itself (
score), not its value (score()). - With
input()instead ofmodel(), the child couldn't write at all —value.setdoesn't exist (TS2339). Rule:input()for data the child displays,model()for controls whose job is to change the parent's value.
Learn more: input() vs model() and Two-way binding with model().
// DeleteButton: deleted = output<void>(); onClick() emits deleted AND dispatches
// new CustomEvent('removed', { bubbles: true }) from its host element.
// Card template: <app-delete-button (deleted)="heard.push('card heard deleted')" />
// Board template: <app-card (deleted)="heard.push('board heard deleted')"
// (removed)="heard.push('board heard DOM removed')" />The user clicks the delete button. What's in heard? (Card declares no deleted output.)
Show answer
['card heard deleted', 'board heard DOM removed']. Outputs don't bubble: emit() only calls the handlers bound on that component's host element in its direct parent's template. The board's (deleted) compiled — Angular treated it as a native DOM listener on <app-card> — and simply never fired. The real DOM CustomEvent did bubble up.
this.deleted.emit()calls the listeners subscribed to thisDeleteButtoninstance's output. The only one is inCard's template, socard heard deleted.emitis synchronous: the parent's handler runs insideemit, as a direct function call. No queue, no propagation.Boardwrites(deleted)on<app-card>, butCardhas nodeletedoutput. The compiler doesn't complain; any unknown(name)becomesaddEventListener('deleted', …)on the element. Angular outputs don't dispatch DOM events, so it never fires.- The
CustomEvent('removed', { bubbles: true })is a real DOM event. It travels up through<app-card>, where the board's(removed)listener hears it. - To reach a grandparent: re-emit through the middle component (
(deleted)="deleted.emit()"with its own output), or move the state into a signal-based service when several levels need it.
Learn more: Component events don't bubble and Child to parent with output().