Angular NotesRohit’s interview study guide
Chapter 03

Templates & Data Binding

Every way data moves between a component and its template — interpolation, property, attribute, class/style, event and two-way binding.

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

#Interpolation

A template is not HTML that Angular "fills in" with string replacement. The compiler turns it into a function that creates the DOM once and then, on every change detection pass, re-evaluates each binding and writes only the ones whose value changed. Every binding syntax in this chapter is a different instruction in that function.

Interpolation — {{ expression }} — is the simplest one: evaluate the expression against the component instance, convert the result to a string, and write it into a text node. A good mental model: the template is a live view of the component's fields. {{ name }} means "this text node always shows whatever this.name is right now".

The expression is a restricted JavaScript expression whose scope is the component instance (no this.), plus any template variables. It can't see globals: {{ Math.max(1, 2) }} fails with TS2339: Property 'Math' does not exist on type '<your component>', and {{ name = 'x' }} fails with "Parser Error: Bindings cannot contain assignments". Angular 22 templates do accept ?., ??, template literals, typeof, ** and in.

greeting.ts
@Component({
  selector: 'app-greeting',
  template: `
    <h1>Hello, {{ name }}!</h1>
    <p>{{ items.length }} items, total {{ price * qty }}</p>
    <p>{{ user?.email ?? 'no email' }}</p>
    <p>[{{ nothing }}] [{{ tags }}] [{{ settings }}]</p>
    <img src="/avatars/{{ id }}.png" alt="{{ name }}'s avatar" />
  `,
})
export class Greeting {
  name = 'Rohit';
  items = ['a', 'b', 'c'];
  price = 2.5;
  qty = 4;
  user: { email?: string } | null = null;
  nothing: string | undefined = undefined;
  tags = ['ng', 'ts'];
  settings = { dark: true };
  id = 42;
}
What’s happening
  1. The <h1> renders Hello, Rohit! — and it is one element with one text node. The compiler joins the static text and the expression into a single instruction, ɵɵtextInterpolate1("Hello, ", ctx.name, "!"), where ctx is the component instance.
  2. {{ items.length }} items, total {{ price * qty }} becomes ɵɵtextInterpolate2(…) and renders 3 items, total 10. Any expression works — arithmetic, property access, method calls — as long as it has no side effects.
  3. user is null, so user?.email short-circuits to undefined and ?? supplies the fallback: no email. Without ?., strict template checking would reject user.email at compile time ("Object is possibly 'null'").
  4. The fourth line shows how values are stringified: undefined (and null) render as an empty string, not the word "undefined"; the array becomes ng,ts (like String(['ng', 'ts'])); the object becomes [object Object]. The real output is [] [ng,ts] [[object Object]].
  5. Interpolation inside an attribute is not an attribute write at all. The compiler turns src="/avatars/{{ id }}.png" into a property binding: ɵɵdomProperty("src", ɵɵinterpolate1("/avatars/", ctx.id, ".png"), ɵɵsanitizeUrl). Note the third argument — because src is a URL, the value also passes through Angular's URL sanitizer (see Angular's built-in XSS protection).
AdvancedCompiled template: create and update blocks

Here's the trimmed compiled output for that template (from a development build). The rf & 1 block runs once to create DOM nodes; the rf & 2 block runs on every check to update them:

Greeting_Template (compiled, trimmed)
function Greeting_Template(rf, ctx) {
  if (rf & 1) {
    ɵɵdomElementStart(0, "h1");
    ɵɵtext(1);
    ɵɵdomElementEnd();
    // … the <p>s …
    ɵɵdomElement(8, "img", 3);
  }
  if (rf & 2) {
    ɵɵadvance();
    ɵɵtextInterpolate1("Hello, ", ctx.name, "!");
    ɵɵadvance(2);
    ɵɵtextInterpolate2("", ctx.items.length, " items, total ", ctx.price * ctx.qty);
    // …
    ɵɵdomProperty("src", ɵɵinterpolate1("/avatars/", ctx.id, ".png"), ɵɵsanitizeUrl)
                 ("alt", ɵɵinterpolate1("", ctx.name, "'s avatar"));
  }
}
What’s happening
  1. The create block builds the elements and empty text nodes, each with a slot index (0, 1, …). This happens once per component instance.
  2. The update block walks the slots in order with ɵɵadvance() and calls one instruction per binding. Each instruction compares the new value with the one it stored last time and touches the DOM only if it changed.
  3. So "re-rendering" a component in Angular never rebuilds its HTML. It re-runs this small update function, which usually ends up writing nothing.

#Property binding

Property binding — [target]="expression" — sets a DOM property (or a directive/component input) to the value of the expression. Not a string version of it: the actual value, whatever its type. [disabled]="isLocked" assigns a boolean, [user]="currentUser" passes an object reference.

The key distinction is attribute vs property. An HTML attribute is what you write in the markup; the browser reads it once to initialise the element. A DOM property is a field on the live JavaScript object, and it's what the browser actually uses from then on. For <input value="Rohit">, the value attribute is the initial value; input.value is what's in the box now. Angular's square brackets set the property, so the mental model is literally element.value = expression — run again whenever the expression's result changes.

profile-form.ts
@Component({
  selector: 'app-profile-form',
  template: `
    <input [value]="name()" [disabled]="locked()" />
    <img [src]="avatarUrl()" [alt]="name()" />
    <button [disabled]="!canSave()">Save</button>
    <button (click)="name.set('Rohit')">Reset</button>
  `,
})
export class ProfileForm {
  name = signal('Rohit');
  locked = signal(false);
  avatarUrl = signal('/avatars/42.png');
  canSave = signal(false);
}
What’s happening
  1. On first render Angular runs input.value = 'Rohit' and input.disabled = false. In the test, input.value is 'Rohit' but input.getAttribute('value') is null — no value attribute was ever written. Property binding doesn't touch attributes.
  2. [disabled]="!canSave()" assigns true to the Save button. Some properties reflect to attributes — disabled does — so the DOM also shows disabled="". That's the browser's doing, not Angular's. When canSave becomes true, Angular assigns false and the browser removes the attribute.
  3. The user types Rohit V in the input. Then something unrelated changes (locked.set(true)) and the component is re-checked. The input still shows Rohit V: Angular compares the binding's new value with the value it wrote last time ('Rohit' vs 'Rohit'), sees no change, and doesn't write. It never reads the DOM.
  4. name.set('Ravi') changes the bound value, so Angular writes input.value = 'Ravi' and the user's text is replaced.
  5. Now the trap: the user types abc, then clicks Reset, which runs name.set('Rohit') — but name already is 'Rohit'. The signal sees an equal value and doesn't notify, the binding's value hasn't changed, so nothing is written. The test confirms the input still shows abc while the signal says Rohit.
AdvancedPitfall: one-way binding drift
AdvancedBinding a child component's input

The same syntax binds a child component's input: <app-user-card [user]="me" /> passes the me object itself to the child's user input. That's how parents pass data down — see Parent to child with input().

#Interpolation vs property binding

For a string property the two forms do the same thing: title="{{ heading }}" and [title]="heading" both end up as element.title = heading. The difference is that interpolation always produces a string, while property binding passes the value through unchanged. That matters as soon as the target isn't a string.

Interpolation x="{{ expr }}"Property binding [x]="expr"
Value writtenString(expr) (null/undefined → '')The value itself, any type
Mixing textYes: src="/img/{{ id }}.png"Needs an expression: [src]="'/img/' + id + '.png'"
Booleansdisabled="{{ false }}" → 'false', which is truthy[disabled]="false" → false
Objects / component inputs[object Object], or a compile errorThe object reference
Compiles toɵɵproperty("x", ɵɵinterpolate(expr))ɵɵproperty("x", expr)
interp-vs-prop.ts
interface User {
  name: string;
}

@Component({
  selector: 'app-user-card',
  template: `<p>{{ user().name }}</p>`,
})
export class UserCard {
  user = input.required<User>();
}

@Component({
  selector: 'app-interp-vs-prop',
  imports: [UserCard],
  template: `
    <button disabled="{{ locked }}">A</button>
    <button [disabled]="locked">B</button>
    <p title="{{ count }}">{{ count }}</p>
    <app-user-card [user]="me" />
  `,
})
export class InterpVsProp {
  locked = false;
  count = 3;
  me: User = { name: 'Rohit' };
}
What’s happening
  1. Button A: interpolation turns false into the string 'false' and runs button.disabled = 'false'. A non-empty string is truthy, so the button is disabled — the test shows disabled: true even though locked is false. This is the classic interpolation bug.
  2. Button B: [disabled]="locked" assigns the boolean false, so the button is enabled. The compiled code shows the whole difference: ɵɵproperty("disabled", ɵɵinterpolate(ctx.locked)) vs ɵɵproperty("disabled", ctx.locked).
  3. title="{{ count }}" sets p.title to the string "3". Fine — title is a string property anyway, so either syntax works.
  4. [user]="me" passes the object to the child's signal input and the card renders Rohit. With user="{{ me }}" instead, strict template checking (on by default) refuses to compile: TS2322: Type 'string' is not assignable to type 'User'. Interpolation can only ever produce a string.
  5. Note the type checker didn't catch button A. Angular deliberately doesn't type-check bindings to native DOM properties (its compiler config has checkTypeOfDomBindings: false), so disabled="{{ locked }}" compiles silently. Only component and directive inputs are type-checked.
AdvancedRule of thumb: interpolation vs property binding

Rule of thumb: use interpolation for text — text content, or a string attribute built from pieces (alt="{{ name }}'s avatar"). Use property binding for everything else: booleans, numbers, objects, and every component input.

What does this render?
HTML
<button disabled="{{ isAdmin }}">Delete</button>
<input type="checkbox" checked="{{ isAdmin }}" />

isAdmin is false.

Show answer

The button is disabled and the checkbox is checked.

What’s happening
  1. Both are interpolations, so isAdmin is converted to the string 'false' first.
  2. Angular then runs button.disabled = 'false' and input.checked = 'false'.
  3. Both are boolean properties, so the browser coerces the string: any non-empty string is true.
  4. The fix is property binding: [disabled]="isAdmin" and [checked]="isAdmin" pass the real boolean.

#Attribute binding

Property binding needs a DOM property to set. Some things are attributes with no matching property: ARIA attributes, data-*, SVG attributes, and a few HTML ones whose property has a different name. For these, [attr.name]="expression" calls setAttribute('name', String(value)) — and a value of null or undefined removes the attribute.

attr-demo.ts
@Component({
  selector: 'app-attr-demo',
  template: `
    <table>
      <tr><td [attr.colspan]="span()">Total</td></tr>
      <tr><td [colSpan]="span()">Total</td></tr>
    </table>
    <button [attr.aria-label]="label()" [attr.data-id]="id()">X</button>
    <button [aria-label]="label()">Y</button>
  `,
})
export class AttrDemo {
  span = signal(2);
  label = signal<string | null>('Close dialog');
  id = signal(7);
}
What’s happening
  1. [attr.colspan] compiles to ɵɵattribute("colspan", ctx.span()) and the cell renders as <td colspan="2">.
  2. Writing [colspan]="span()" instead fails to compile with NG8002: Can't bind to 'colspan' since it isn't a known property of 'td'. The DOM property is spelled colSpan (camel case); colspan is only the attribute name. [colSpan] works and compiles to ɵɵdomProperty("colSpan", …) — the browser then reflects it to the colspan attribute.
  3. The first button gets aria-label="Close dialog" data-id="7". The number 7 was turned into the string "7" — attributes are always strings.
  4. The second button uses [aria-label] with no attr. prefix. In Angular 22 this compiles, and the generated code is ɵɵattribute("aria-label", …) — the compiler knows ARIA names are attributes and maps them for you. [attr.aria-label] remains the explicit, works-everywhere form.
  5. label.set(null) removes the attribute from both buttons — the test shows <button data-id="7">X</button>. That's how you make an ARIA attribute conditional: bind null when it shouldn't be there, rather than an empty string.
Advanced[prop] vs [attr.x] reference table
UseWhen
[prop]The element has a DOM property: value, disabled, checked, src, hidden, colSpan, innerHTML
[attr.x]Only an attribute exists: aria-*, data-*, role, SVG attributes, colspan by its attribute name
AdvancedDiagnosing NG8002 on native elements

#Class and style binding

Added

Classes and inline styles have their own binding forms, because you rarely want to replace the whole class attribute — you want to toggle individual classes while leaving the static ones alone. Angular merges every source of classes on an element (static class="…", [class], [class.x], host bindings from directives) into one result.

SyntaxEffect
[class.active]="isActive"Adds active when truthy, removes it when falsy
[class]="'a b'" / ['a', 'b'] / { a: true, b: false }Several classes at once; static classes are kept
[style.width.px]="w"width: <w>px — the suffix is the unit (px, %, em, rem…)
[style.background-color]="c"One style property (dash-case or camelCase both work)
[style]="{ 'font-weight': 'bold', opacity: 0.5 }"Several style properties at once
class-style.ts
@Component({
  selector: 'app-class-style',
  imports: [NgClass],
  template: `
    <li class="item" [class.active]="active()" [class.done]="done()">One</li>
    <div class="card" [class]="{ highlighted: highlighted(), compact: compact() }">Card</div>
    <div [class]="['a', 'b']">List</div>
    <div [style.width.px]="width()" [style.background-color]="color()">Bar</div>
    <div [style]="{ 'font-weight': 'bold', opacity: 0.5 }">Obj</div>
    <div [ngClass]="{ highlighted: highlighted() }">Old</div>
  `,
})
export class ClassStyle {
  active = signal(true);
  done = signal(false);
  highlighted = signal(true);
  compact = signal(false);
  width = signal(120);
  color = signal('teal');
}
What’s happening
  1. The <li> renders as class="item active". The static item survives; [class.active] adds one class; [class.done] is false so nothing is added. These compile to ɵɵclassProp("active", …)("done", …).
  2. The card renders class="card highlighted": [class] with an object adds the keys whose values are truthy, and the static card is merged in rather than overwritten.
  3. The object literal { highlighted: …, compact: … } is not rebuilt on every check. The compiler wraps it in ɵɵpureFunction2(…), which returns the same object until one of its two inputs changes — so Angular doesn't diff the class map needlessly.
  4. [style.width.px]="width()" with 120 produces width: 120px; together with the colour binding the bar renders style="width: 120px; background-color: teal;".
  5. After active=false, done=true, highlighted=false, compact=true, width=50, one check later the DOM is class="item done", class="card compact" and width: 50px — Angular removed and added exactly the classes that changed.
  6. [ngClass] gives the same result here (class="highlighted"), but when every key is false it leaves an empty class="" attribute, and it needs NgClass in imports. Native [class] needs no import.
AdvancedNgClass and NgStyle in Angular 22

NgClass and NgStyle are not deprecated in Angular 22 (unlike NgIf/NgFor), but they're no longer needed: [class] and [style] accept the same strings, arrays and objects. Angular ships migrations that rewrite them where possible: ng generate @angular/core:ngclass-to-class and ng generate @angular/core:ngstyle-to-style. In Rohit's Angular 16 project (techStatus) you'll see [ngClass] everywhere; in new code, prefer the native bindings.

AdvancedPitfall: direct className writes

#Event binding and $event

Event binding — (eventName)="statement" — is the opposite direction: view → component. Angular adds a listener to the element and, when the event fires, runs the statement in the component's context. Unlike binding expressions, an event statement may have side effects: call methods, assign fields, signal.set(…), even several statements separated by ;.

Inside the statement, $event is whatever the event delivered. For a DOM event that's the native event object (InputEvent, KeyboardEvent, PointerEvent for click). For a component output(), it's the emitted value. After the handler runs, Angular marks the component for checking, so anything it changed shows up in the view.

todo-input.ts
@Component({
  selector: 'app-todo-input',
  template: `
    <input
      [value]="text()"
      (input)="onInput($event)"
      (keydown.enter)="add()"
      (keydown.escape)="text.set('')"
    />
    <button (click)="add()">Add</button>
    <ul>
      @for (t of todos(); track $index) {
        <li>{{ t }}</li>
      }
    </ul>
  `,
})
export class TodoInput {
  text = signal('');
  todos = signal<string[]>([]);

  onInput(event: Event) {
    this.text.set((event.target as HTMLInputElement).value);
  }

  add() {
    if (!this.text().trim()) return;
    this.todos.update((list) => [...list, this.text()]);
    this.text.set('');
  }
}
What’s happening
  1. Typing Buy milk fires input. Angular calls onInput($event) with the native InputEvent; the handler reads event.target.value and the signal becomes 'Buy milk'. Combined with [value]="text()" this is a hand-written two-way binding.
  2. Pressing Enter fires keydown. (keydown.enter) is an Angular key event filter: the listener only runs your statement when event.key is Enter. add() appends to todos and resets text to ''.
  3. Because text went 'Buy milk' → '', the [value] binding changed, so Angular writes input.value = '' — the box clears. The test shows one <li> and an empty input.
  4. Escape runs text.set('') directly in the template — no method needed for a one-liner.
  5. Shift+Enter does not trigger (keydown.enter) — in the test nothing was added. The filter requires the modifier keys to match exactly; to catch it you'd bind (keydown.shift.enter). Other filters: (keydown.control.s), (keyup.arrowdown), (keydown.space).
  6. Why onInput(event: Event) casts: Event.target is typed EventTarget | null, which has no value. Inline, though, (input)="text.set($event.target.value)" on an <input> does compile in Angular 22 — see the next block.
AdvancedTyping $event under strict templates

How $event is typed under strict templates (the default):

event typing
<!-- ✅ compiles: <input> is a void element, so $event.target is asserted to be the input -->
<input (input)="text.set($event.target.value)" />

<!-- ❌ TS2531: Object is possibly 'null'.
     ❌ TS2339: Property 'value' does not exist on type 'EventTarget'. -->
<textarea (input)="text.set($event.target.value)"></textarea>

<!-- ✅ $event is a PointerEvent for (click) -->
<button (click)="log($event.pointerId)">p</button>
What’s happening
  1. The template type checker types $event from the DOM's event map: input → InputEvent, keydown → KeyboardEvent, click → PointerEvent.
  2. Normally $event.target is just EventTarget | null, because an event can bubble up from a child. A void element (<input>, <img>…) can't have children, so the target can only be the element itself. Angular 22's type checker uses that: it asserts $event.target to the element's type, and $event.target.value compiles on an <input>. (The compiler enables this for @angular/core ≥ 20.2.)
  3. A <textarea> isn't void, so the same expression fails with the two real errors shown. Use a template reference (#area … area.value, see Template reference variables and @let) or a typed method instead.
Advancedwindow: and document: event targets

#Two-way binding

Two-way binding keeps a value in the parent and a value in the child (or a form control) in sync in both directions. Angular doesn't have a special two-way mechanism — [(x)]="value" is pure syntactic sugar for a property binding plus an event binding:

[(size)]="fontSize" means [size]="fontSize" (sizeChange)="fontSize = $event"

The brackets-inside-parentheses shape is called banana in a box: [()]. The rule the compiler relies on is a naming convention: the output must be called <input name>Change.

two-way.ts
// Old style (Angular 16): an @Input plus an @Output named <input>Change.
@Component({
  selector: 'app-sizer-old',
  template: `<button (click)="inc()">+</button><span>{{ size }}</span>`,
})
export class SizerOld {
  @Input() size = 16;
  @Output() sizeChange = new EventEmitter<number>();

  inc() {
    this.size++;
    this.sizeChange.emit(this.size);
  }
}

// Modern: model() is the input and the output in one.
@Component({
  selector: 'app-sizer',
  template: `<button (click)="size.update(s => s + 1)">+</button><span>{{ size() }}</span>`,
})
export class Sizer {
  size = model(16);
}

@Component({
  selector: 'app-two-way',
  imports: [SizerOld, Sizer, FormsModule],
  template: `
    <app-sizer-old [(size)]="oldSize" />
    <app-sizer [(size)]="fontSize" />
    <app-sizer [size]="fontSize()" (sizeChange)="fontSize.set($event)" />
    <p [style.font-size.px]="fontSize()">{{ oldSize }} / {{ fontSize() }}</p>
    <input [(ngModel)]="name" />
    <p>Hello {{ name() }}</p>
  `,
})
export class TwoWay {
  oldSize = 12;
  fontSize = signal(16);
  name = signal('Rohit');
}
What’s happening
  1. <app-sizer-old [(size)]="oldSize">: the parent pushes 12 into @Input() size. Clicking + makes the child's own copy 13 and emits sizeChange with 13; the desugared event binding runs oldSize = 13 in the parent. Both sides now hold 13.
  2. <app-sizer [(size)]="fontSize"> binds to a signal. The compiler generates ɵɵtwoWayBindingSet(ctx.fontSize, $event) || (ctx.fontSize = $event): if the target is a writable signal it calls .set($event), otherwise it assigns. So you write [(size)]="fontSize", not fontSize().
  3. Inside Sizer, model(16) is a writable signal that is also an input and an output named sizeChange. size.update(s => s + 1) changes the local value and emits the event in one step. The test: one click → fontSize is 17, and the <p> gets font-size: 17px.
  4. The third <app-sizer> is the hand-desugared form. Clicking it emits 18, fontSize.set(18) runs, and because fontSize is bound into the other two places, all of them show 18 — one source of truth, many views.
  5. [(ngModel)]="name" is the same pattern from FormsModule: [ngModel] writes into the input, (ngModelChange) fires as the user types. Typing Ravi makes name() 'Ravi'; name.set('Asha') puts Asha back in the box. Forget FormsModule in imports and you get NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'.
AdvancedPitfall: two-way binding to a signal call

#The universal binding pattern

All of Angular's template syntax follows one pattern. The left side of = is the target — a name that belongs to the element, directive or child component. The right side is the source — an expression evaluated against your component. The punctuation around the target tells you which way the data flows.

SyntaxDirectionTargetExample
{{ expr }}component → viewtext node (or property){{ user.name }}
[target]="expr"component → viewDOM property or input[disabled]="busy"
[attr.x] / [class.x] / [style.x]component → viewattribute / class / style[attr.aria-label]="label"
(target)="statement"view → componentDOM event or output(click)="save()"
[(target)]="field"bothinput + targetChange output[(ngModel)]="name"

Memory aid: [] is data in, () is events out, [()] is both. Square brackets mean "evaluate the right side as an expression"; without them, title="Hello" is a static attribute and [title]="Hello" would look up a field called Hello.

The long-hand forms still compile in Angular 22 — bind-value="v" works like [value]="v" and on-input="…" like (input)="…" (both verified in a test) — but nobody writes them; you'll only see them in very old code.

Target or source?

In <app-user-card [user]="currentUser" (selected)="onSelect($event)" />, which names must exist on the child component and which on the parent?

Show answer

user and selected belong to the child; currentUser and onSelect belong to the parent.

What’s happening
  1. [user] is a target: Angular looks for an input called user on the child (or a DOM property, if no directive claims it). If none exists you get NG8002: Can't bind to 'user'….
  2. "currentUser" is the source: evaluated in the parent template's context, so it's the parent's field.
  3. (selected) is a target too — an output on the child.
  4. "onSelect($event)" is a statement in the parent's context; $event is the value the child emitted.

#Binding to component properties and signals

From Rohit's notes, marked "Very important !!!!": a template only re-renders when Angular knows something changed. In Angular 22 that knowledge comes from three places: signals read by the template, events handled by the template (and inputs changing), and explicit calls like markForCheck(). A plain class field changing on its own tells Angular nothing.

Two Angular 22 defaults make this sharper than it was in Angular 16: there is no zone.js (new apps are zoneless, so setTimeout or a fetch callback doesn't trigger change detection by itself), and components are OnPush by default (ChangeDetectionStrategy in core.d.ts says "NOTE: OnPush is enabled by default"). See Zoneless Angular and OnPush.

counter.ts
@Component({
  selector: 'app-counter',
  template: `
    <p>plain: {{ plainCount }}</p>
    <p>signal: {{ count() }}</p>
    <button (click)="clickBoth()">+1 both</button>
    <button (click)="count.update(c => c + 10)">+10</button>
  `,
})
export class Counter {
  plainCount = 0;
  count = signal(0);

  clickBoth() {
    this.plainCount++;
    this.count.update((c) => c + 1);
  }

  later() {
    setTimeout(() => (this.plainCount = 5)); // nobody tells Angular
  }
}
What’s happening
  1. In the template, a signal is read by calling it: count(). That call does two things — returns the value, and registers this component's view as a consumer of the signal. From then on, count.set() / update() marks the view dirty and schedules a change detection pass.
  2. Clicking "+1 both" changes both values. Both update on screen (plain: 1, signal: 1) because the click was a template event — Angular marks the component dirty after any template event handler, and the check re-reads every binding in the view, including plainCount.
  3. later() changes only the plain field from a timer. The test calls it, waits for the timer and for Angular to be stable, and the view still shows plain: 0 while the field is 5. No zone saw the timer, no signal changed, no event happened — so nothing scheduled a check.
  4. If the same timer also updates a signal read by this view, the signal schedules the check, and the plain field gets re-read as a side effect. It "works", but only by accident — which is exactly the kind of bug that's hard to track down.
  5. count.update(c => c + 10) in the template: Angular 22 accepts arrow functions in template statements, so simple updates don't need a method. The test shows signal: 12 after the click.
AdvancedUncalled signal in a template: NG8109

Forgetting the parentheses is the most common signal mistake:

forgot to call the signal
<p>{{ count }}</p>
What’s happening
  1. The build doesn't fail, but it warns twice: NG8109: count is a function and should be invoked: count() and NG8117: Function in text interpolation should be invoked: count().
  2. At runtime the signal function is stringified. In a development build the page shows [Signal (count): 5] — the compiler adds the field name as a debug name — not 5.
  3. Because the signal was never called, the view also didn't subscribe to it: later count.set(6) wouldn't schedule a re-render.

#Avoid heavy work in templates

Every binding in a template is re-evaluated each time that component's view is checked — Angular has to call the expression to find out whether its value changed. If the expression is a method call, the method runs on every check, even when its inputs haven't changed. For a cheap getter that's fine; for filtering or totalling a large list, it's wasted work on every keystroke and click.

The fix is to cache the result so that a check costs a comparison, not a recomputation: a computed() signal (recomputes only when a signal it reads changes), a pure pipe (re-runs only when its input reference changes), or a value you precompute when the data changes.

cart.ts
@Component({
  selector: 'app-cart',
  template: `
    <p>method: {{ total() }}</p>
    <p>computed: {{ totalC() }}</p>
    <p>clicks: {{ clicks() }}</p>
    <button (click)="clicks.update(c => c + 1)">click</button>
    <button (click)="addItem()">add</button>
  `,
})
export class Cart {
  items = signal<Item[]>([
    { name: 'pen', price: 2 },
    { name: 'book', price: 10 },
  ]);
  clicks = signal(0);
  methodCalls = 0;
  computedCalls = 0;

  total() {
    this.methodCalls++;
    return this.items().reduce((sum, i) => sum + i.price, 0);
  }

  totalC = computed(() => {
    this.computedCalls++;
    return this.items().reduce((sum, i) => sum + i.price, 0);
  });

  addItem() {
    this.items.update((list) => [...list, { name: 'bag', price: 30 }]);
  }
}
What’s happening
  1. First render: both show 12. Counters: methodCalls = 1, computedCalls = 1.
  2. Click "click" three times. Each click changes clicks, which re-checks the view, which evaluates every binding again. The compiled update code is just ɵɵtextInterpolate1("method: ", ctx.total()) — Angular can't know total() would return the same thing without calling it. After three clicks: methodCalls = 4, computedCalls = 1.
  3. Why didn't totalC run? Calling totalC() in the template returns its memoized value: a computed only re-runs its function when one of the signals it read (items) has changed since last time. clicks isn't one of them.
  4. Click "add": items changes, so both recompute and show 42. Counters: methodCalls = 5, computedCalls = 2.
  5. Here the method only ran on checks of this component, because Angular 22 components are OnPush by default. With ChangeDetectionStrategy.Eager (the old Default) the component would be checked on every change detection pass in the app, and total() would run every time anything anywhere changed.
AdvancedWhat belongs in a template

What belongs in a template: reading fields and signals, simple formatting, and cheap boolean logic. What doesn't: filtering/sorting/mapping lists, new Date() or Math.random() (a different value on every call, so the view changes whenever it happens to be checked), and anything with side effects. Move them into computed() or a pipe — see Pipes vs method calls and computed().

AdvancedHow ɵɵpureFunction caches literals

#Template reference variables and @let

Added

Templates have two ways to declare names of their own.

A template reference variable — #name on an element — gives the rest of the template a reference to that element (an HTMLInputElement, say). On a component tag it refers to the component instance; on <ng-template> to a TemplateRef; and with a value, #f="ngForm", to whatever the directive exports under that name.

@let declares a local, read-only variable computed from an expression: @let total = price() * qty();. It re-evaluates on every check, like any binding, and is visible after its declaration, including inside nested blocks.

refs.ts
@Component({
  selector: 'app-refs',
  template: `
    <input #box (keydown.enter)="add(box.value); box.value = ''" />
    <button (click)="box.focus()">Focus</button>
    <button (click)="add(box.value)">Add {{ box.value }}</button>

    @let fullName = first() + ' ' + last();
    @let count = names().length;
    <p>{{ fullName }} has {{ count }} names</p>
    <ul>
      @for (n of names(); track $index) {
        <li>{{ n }} ({{ fullName }})</li>
      }
    </ul>
  `,
})
export class Refs {
  first = signal('Rohit');
  last = signal('Varma');
  names = signal<string[]>([]);

  add(value: string) {
    if (value) this.names.update((list) => [...list, value]);
  }
}
What’s happening
  1. #box names the <input> element. In the Enter handler, box.value reads the element's current value property — no (input) handler or signal needed — and box.value = '' clears it directly. Typing Ravi + Enter → names is ['Ravi'] and the box is empty.
  2. (click)="box.focus()" calls a DOM method on the referenced element; the test confirms document.activeElement is the input. A reference can be used anywhere in the same template, even above the element it names (<p>{{ later.placeholder }}</p><input #later placeholder="hello"> renders hello).
  3. @let fullName = first() + ' ' + last(); renders Rohit Varma has 0 names, then … has 1 names after the add. After last.set('V') it becomes Rohit V on the next check.
  4. Inside the @for, each <li> reads fullName from the outer scope: Ravi (Rohit Varma). @let variables are visible to nested blocks.
  5. @let is read-only: (click)="fullName = 'x'" fails with NG8015: Cannot assign to @let declaration 'fullName'. And it's not hoisted: using it above its declaration fails with NG8016: Cannot read @let declaration 'fullName' before it has been defined.
AdvancedPitfall: stale template reference values
AdvancedPitfall: names clashing with class members
AdvancedCommon @let uses

@let is especially handy for reading a signal or an async value once and reusing it — @let user = user$ | async; — and for giving @switch a narrowable value (see @switch).

#Angular's built-in XSS protection

Added

Cross-site scripting (XSS) is getting attacker-controlled text interpreted as code in a user's browser — a comment containing <img src=x onerror="steal()">, for example. Angular's defence is that it treats every bound value as untrusted by default and decides what to do based on where it's going:

  • Text ({{ }}, [textContent]): written as text, never parsed as HTML. Always safe.
  • HTML ([innerHTML]): parsed, then sanitized — scripts, event-handler attributes and dangerous URLs are stripped.
  • URL ([href], [src]): checked; a javascript: URL is neutralised by prefixing unsafe:.
  • Resource URL (<iframe src>, <script src>): can't be bound from a plain string at all — <iframe [src]="url"> with a string throws NG0904: unsafe value used in a resource URL context at runtime.

The server side of this story — output encoding, CSP, cookies — is in the Node site's Cross-site scripting (XSS) topic.

comment.ts
@Component({
  selector: 'app-comment',
  template: `
    <p>{{ comment }}</p>
    <div [innerHTML]="comment"></div>
    <a [href]="website">website</a>
    <div [innerHTML]="trusted"></div>
  `,
})
export class Comment {
  comment = '<b>Nice!</b><img src="x" onerror="alert(1)"><script>alert(2)</script>';
  website = 'javascript:alert(3)';

  private sanitizer = inject(DomSanitizer);
  // ⚠️ Only for HTML you produced or sanitised yourself — this turns the protection off.
  trusted: SafeHtml = this.sanitizer.bypassSecurityTrustHtml('<img src="x" onerror="alert(4)">');
}
What’s happening
  1. Interpolation writes the comment as text: the page literally shows <b>Nice!</b><img …><script>…, and the paragraph's innerHTML is the escaped &lt;b&gt;Nice!&lt;/b&gt;…. Nothing is parsed, so nothing runs.
  2. [innerHTML] compiles to ɵɵdomProperty("innerHTML", ctx.comment, ɵɵsanitizeHtml). The sanitizer keeps safe markup and drops the rest: the div's real innerHTML is <b>Nice!</b><img src="x"> — the onerror attribute and the whole <script> are gone.
  3. In development mode Angular tells you it did this: WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss. Treat that warning as a bug report — either the data is malicious or your HTML uses something unsupported.
  4. [href] goes through ɵɵsanitizeUrl. The link's href becomes unsafe:javascript:alert(3) (clicking it does nothing) and the console says WARNING: sanitizing unsafe URL value javascript:alert(3) (see …).
  5. bypassSecurityTrustHtml(…) wraps the string in a SafeHtml marker that the sanitizer lets through untouched: the last div contains <img src="x" onerror="alert(4)"> with the handler intact. In a browser, that image fails to load and the script runs. That's why these methods are named so loudly.
AdvancedBindings refused at compile time

Angular also refuses some bindings at compile time: [onclick]="…" fails with NG8002: Binding to event property 'onclick' is disallowed for security reasons, please use (click)=..., and [attr.onclick] with NG5002: Binding to event attribute 'onclick' is disallowed for security reasons….

AdvancedPitfall: bypassing the sanitizer

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.