Skip to main content
Published / updated

Templates and Data Binding

Before you start

You need: Angular fundamentals (Article 01).

Time: about 45 minutes, plus the practice.

Learning objective

Move data between a component class and its template in every direction, and between parent and child components.

Topics

  • Interpolation
  • Property binding
  • Event binding
  • Two-way binding
  • @Input and @Output
  • Signals
  • Template reference variables
  • Change detection
  • Diagnosing binding errors

The four bindings

SyntaxDirectionUse
{{ value }}Class → templateDisplay a value
[property]="value"Class → templateSet a property
(event)="handler()"Template → classRespond to an event
[(ngModel)]="value"BothTwo-way, forms

Interpolation

<h1>{{ student.name }}</h1>
<p>{{ student.className }} - {{ student.section }}</p>
<p>{{ student.marks + bonusMarks }}</p>
<p>{{ getGrade(student.marks) }}</p>
<p>{{ isActive ? 'Active' : 'Inactive' }}</p>
<p>{{ student.address ?? 'Not recorded' }}</p>

Whatever it evaluates to is converted to a string and HTML-encoded. A parent name entered as <script>alert(1)</script> renders as visible text, not executable script — the same XSS protection Razor gives you, and for the same reason.

Do not call a method in an interpolation on a hot path. It re-runs on every change-detection cycle, which is far more often than you expect:

<!-- Runs on every cycle, for every row -->
<td>{{ calculatePercentage(student) }}</td>
// Compute once, when the data arrives
students = raw.map(s => ({ ...s, percentage: this.calculatePercentage(s) }));
<td>{{ student.percentage }}</td>

Property binding

<img [src]="student.photoUrl" [alt]="student.name">
<button [disabled]="isSaving">Save</button>
<app-student-card [student]="selectedStudent" />

<div [class.active]="isActive" [class.error]="hasError"></div>
<div [ngClass]="{ active: isActive, error: hasError }"></div>
<div [style.color]="statusColour"></div>
<div [ngStyle]="{ 'font-weight': isBold ? '600' : '400' }"></div>

<input [attr.aria-label]="label" [attr.aria-invalid]="hasError">

Square brackets bind an expression; without them the value is a literal string:

<button disabled="false"> <!-- disabled — "false" is a non-empty string -->
<button [disabled]="false"> <!-- enabled — the boolean false -->

That trips up everyone once. The plain attribute form takes text; the bound form takes a TypeScript expression.

[attr.x] exists because some things are attributes, not DOM properties. aria-* and colspan have no matching property, so [aria-label] fails and [attr.aria-label] works.

Prefer [class.x] over [style.x]. An inline style beats every stylesheet rule, so it cannot be themed or overridden — the same rule as in plain JavaScript.

Event binding

<button (click)="save()">Save</button>
<button (click)="deleteStudent(student.publicId)">Delete</button>
<input (input)="onSearch($event)">
<input (keyup.enter)="search()">
<form (ngSubmit)="onSubmit()"></form>
<div (click)="select()" (keydown.enter)="select()" tabindex="0" role="button"></div>

$event is the DOM event, or the value a child component emitted:

onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchTerm = input.value;
}

The cast is needed because event.target is typed EventTarget | null.

(keyup.enter) is a key filter — Angular handles the key check for you.

A <div> with (click) is not accessible. It cannot be tabbed to and does not respond to Enter. Use a <button>, or add tabindex, role and a keydown handler as above — the same rule as plain HTML.

Two-way binding

import { FormsModule } from '@angular/forms';

@Component({
standalone: true,
imports: [FormsModule],
// ...
})
<input [(ngModel)]="searchTerm" name="searchTerm">
<p>Searching for: {{ searchTerm }}</p>

[(ngModel)] is shorthand for a property binding plus an event binding:

<input [ngModel]="searchTerm" (ngModelChange)="searchTerm = $event" name="searchTerm">

Splitting it is useful when you need to act on the change:

<input [ngModel]="searchTerm" (ngModelChange)="onSearchChanged($event)" name="searchTerm">

FormsModule must be imported, and ngModel inside a <form> needs a name attribute — omitting it throws at runtime.

@Input and @Output

Data flows down through inputs and up through outputs.

@Component({
selector: 'app-student-card',
standalone: true,
imports: [CommonModule],
templateUrl: './student-card.component.html'
})
export class StudentCardComponent {
@Input({ required: true }) student!: Student;
@Input() showActions = true;
@Input() highlightTerm = '';

@Output() selected = new EventEmitter<string>();
@Output() deleteRequested = new EventEmitter<string>();

onSelect(): void {
this.selected.emit(this.student.publicId);
}
}
<!-- child template -->
<article class="student-card" (click)="onSelect()">
<h3>{{ student.name }}</h3>
<p>{{ student.rollNumber }}</p>

@if (showActions) {
<button type="button" (click)="deleteRequested.emit(student.publicId); $event.stopPropagation()">
Delete
</button>
}
</article>
<!-- parent template -->
@for (student of students; track student.publicId) {
<app-student-card
[student]="student"
[showActions]="canManage"
(selected)="onStudentSelected($event)"
(deleteRequested)="confirmDelete($event)" />
}

@Input({ required: true }) makes the parent's binding mandatory — a compile-time error if omitted, which is far better than a runtime undefined.

A child must not modify its input object. Angular does not prevent it, and it produces a state change the parent never authorised:

// Wrong — mutates the parent's data
markInactive(): void {
this.student.status = 'Inactive';
}

// Right — ask the parent
markInactive(): void {
this.statusChangeRequested.emit(this.student.publicId);
}

Name outputs as events, not as handlers. selected, not onSelect — the parent writes (selected)="...", and (onSelect)="..." reads badly.

Detecting an input change:

import { OnChanges, SimpleChanges } from '@angular/core';

export class StudentCardComponent implements OnChanges {
@Input({ required: true }) student!: Student;

ngOnChanges(changes: SimpleChanges): void {
if (changes['student'] && !changes['student'].firstChange) {
this.recalculate();
}
}
}

Signals

The modern reactive primitive, and the direction Angular is moving.

import { signal, computed, effect } from '@angular/core';

export class StudentListComponent {
students = signal<Student[]>([]);
searchTerm = signal('');

filtered = computed(() =>
this.students().filter(s =>
s.name.toLowerCase().includes(this.searchTerm().toLowerCase())));

totalCount = computed(() => this.filtered().length);

constructor() {
effect(() => {
console.log(`Showing ${this.totalCount()} students`);
});
}

setSearch(term: string): void {
this.searchTerm.set(term);
}

addStudent(student: Student): void {
this.students.update(list => [...list, student]);
}
}
<input [value]="searchTerm()" (input)="setSearch($any($event.target).value)">

<p>{{ totalCount() }} students</p>

@for (student of filtered(); track student.publicId) {
<app-student-card [student]="student" />
}

Read a signal by calling it. searchTerm() in the template, not searchTerm.

SignalPlain property
Readvalue()value
Write.set() / .update()Assignment
Derived valuescomputed()A getter, recomputed every cycle
Change detectionOnly what depends on itThe whole component tree

computed caches and recalculates only when a dependency changes — unlike a getter or a method call in a template, which runs on every cycle. On a list page that is the difference between smooth and sluggish.

Signal-based inputs and outputs are the newer form:

import { input, output } from '@angular/core';

export class StudentCardComponent {
student = input.required<Student>();
showActions = input(true);

selected = output<string>();
}
<h3>{{ student().name }}</h3>

Use signals in new code. @Input/@Output remain fully supported and are what you will find in existing projects.

Template reference variables

<input #searchBox type="text">
<button (click)="search(searchBox.value)">Search</button>

<app-student-card #card [student]="student" />
<button (click)="card.refresh()">Refresh</button>

#name gives the element or component instance a name usable elsewhere in the same template.

To reach one from the class:

import { ViewChild, ElementRef, AfterViewInit } from '@angular/core';

export class StudentListComponent implements AfterViewInit {
@ViewChild('searchBox') searchBox!: ElementRef<HTMLInputElement>;

ngAfterViewInit(): void {
this.searchBox.nativeElement.focus();
}
}

@ViewChild is only populated in ngAfterViewInit, not in ngOnInit. Accessing it earlier gives undefined — a common and confusing first error.

The signal form avoids the lifecycle question:

import { viewChild } from '@angular/core';

searchBox = viewChild<ElementRef<HTMLInputElement>>('searchBox');

Change detection

Angular re-renders when something might have changed. By default it checks the entire component tree after any event, timer or HTTP response.

That is fast enough for most applications and becomes a problem on large lists — which is why a method called from a template is expensive.

import { ChangeDetectionStrategy } from '@angular/core';

@Component({
selector: 'app-student-card',
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})

OnPush tells Angular to check the component only when an @Input reference changes, an event fires inside it, or a signal it reads changes.

// Does NOT trigger OnPush — same array reference
this.students.push(newStudent);

// Does — a new reference
this.students = [...this.students, newStudent];

OnPush requires immutable updates. Mutating an array or object in place changes nothing Angular can see, and the view silently does not update — the classic OnPush bug.

Signals sidestep this entirely: a signal write notifies exactly what depends on it, regardless of strategy. Signals plus OnPush is the modern default, and it is where Angular is heading.

Diagnosing binding errors

ErrorCause
Can't bind to 'x' since it isn't a known propertyThe directive or component is not imported, or a typo in the name
'app-student-card' is not a known elementComponent not in imports
NG0100: ExpressionChangedAfterItHasBeenCheckedErrorA value changed after change detection ran
Property is undefined in ngOnInit@ViewChild — use ngAfterViewInit
The view does not updateOnPush with a mutated object, or a signal not written through .set()
ngModel throws inside a formMissing name attribute
A button is always disableddisabled="false" instead of [disabled]="false"

NG0100 deserves explanation. It means a value changed during rendering — usually because ngAfterViewInit set a property the template reads. The fix is to make the change before rendering, or to defer it:

ngAfterViewInit(): void {
setTimeout(() => this.isReady = true);
}

It only appears in development mode, where Angular runs a second verification pass. It is a warning that your data flow is circular, not a bug in Angular.

Errors you will hit

MessageCauseFix
Property 'x' does not exist on typeTemplate references something not on the classAdd it, or fix the name
Can't bind to 'y' since it isn't a known propertyMissing import, or a typo in the bindingImport the module; check spelling
Value shows as [object Object]Interpolated an objectInterpolate a property, or use a pipe
Two-way binding does not update[(ngModel)] without FormsModuleImport it
A child changes the parent's objectMutated an @InputEmit an event instead

Square brackets bind a property; parentheses bind an event; both together are two-way. Most binding errors are one of the three written in the wrong form.

Common mistakes

  • disabled="false" instead of [disabled]="false"
  • [aria-label] instead of [attr.aria-label]
  • Calling a method from a template on a hot path
  • A child mutating its @Input object
  • Outputs named onX rather than as events
  • @ViewChild accessed in ngOnInit
  • OnPush with in-place mutation
  • Reading a signal without calling it
  • ngModel in a form with no name
  • Missing FormsModule import
  • [style.x] where [class.x] belongs
  • A <div> with (click) and no keyboard support

Practice

The course exercise is build reusable components.

  1. Build StudentCardComponent with @Input({ required: true }). Omit the binding in the parent and record the compile error.
  2. Add @Output() selected and handle it in the parent.
  3. Write disabled="false" on a button. Confirm it is disabled, then fix it.
  4. Bind aria-label without attr.. Record the error, then fix it.
  5. Call a method from an interpolation and log inside it. Count how many times it runs when you type in an unrelated input.
  6. Precompute the value instead and compare.
  7. Have the child mutate student.status directly. Confirm the parent's data changed, then convert it to an output.
  8. Convert the component to signals with signal, computed and input.required. Compare with the property version.
  9. Add computed for a filtered list and confirm it recalculates only when a dependency changes.
  10. Add ChangeDetectionStrategy.OnPush. Update the array with push and confirm the view does not change. Fix it with a spread.
  11. Access a @ViewChild in ngOnInit and record undefined. Move it to ngAfterViewInit.
  12. Set a property in ngAfterViewInit that the template reads. Record NG0100, then fix it.
  13. Render a student named <script>alert(1)</script> and confirm it displays as text.

Exercises 5, 7 and 10 correspond to three real performance and correctness bugs.

You can now

  • Move data in every direction between class and template
  • Choose interpolation, property, event or two-way binding
  • Pass data in with @Input and out with @Output
  • Say why a child must not mutate an @Input
  • Read a template compile error

Review questions

  1. Why does disabled="false" disable a button?
  2. Why is [attr.aria-label] needed rather than [aria-label]?
  3. What must change for an OnPush component to re-render?
  4. Why is computed() better than a getter called from a template?

Next: Directives and pipes