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
@Inputand@Output- Signals
- Template reference variables
- Change detection
- Diagnosing binding errors
The four bindings
| Syntax | Direction | Use |
|---|---|---|
{{ value }} | Class → template | Display a value |
[property]="value" | Class → template | Set a property |
(event)="handler()" | Template → class | Respond to an event |
[(ngModel)]="value" | Both | Two-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.
| Signal | Plain property | |
|---|---|---|
| Read | value() | value |
| Write | .set() / .update() | Assignment |
| Derived values | computed() | A getter, recomputed every cycle |
| Change detection | Only what depends on it | The 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
| Error | Cause |
|---|---|
Can't bind to 'x' since it isn't a known property | The directive or component is not imported, or a typo in the name |
'app-student-card' is not a known element | Component not in imports |
NG0100: ExpressionChangedAfterItHasBeenCheckedError | A value changed after change detection ran |
Property is undefined in ngOnInit | @ViewChild — use ngAfterViewInit |
| The view does not update | OnPush with a mutated object, or a signal not written through .set() |
ngModel throws inside a form | Missing name attribute |
| A button is always disabled | disabled="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
| Message | Cause | Fix |
|---|---|---|
Property 'x' does not exist on type | Template references something not on the class | Add it, or fix the name |
Can't bind to 'y' since it isn't a known property | Missing import, or a typo in the binding | Import the module; check spelling |
Value shows as [object Object] | Interpolated an object | Interpolate a property, or use a pipe |
| Two-way binding does not update | [(ngModel)] without FormsModule | Import it |
| A child changes the parent's object | Mutated an @Input | Emit 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
@Inputobject - Outputs named
onXrather than as events @ViewChildaccessed inngOnInitOnPushwith in-place mutation- Reading a signal without calling it
ngModelin a form with noname- Missing
FormsModuleimport [style.x]where[class.x]belongs- A
<div>with(click)and no keyboard support
Practice
The course exercise is build reusable components.
- Build
StudentCardComponentwith@Input({ required: true }). Omit the binding in the parent and record the compile error. - Add
@Output() selectedand handle it in the parent. - Write
disabled="false"on a button. Confirm it is disabled, then fix it. - Bind
aria-labelwithoutattr.. Record the error, then fix it. - Call a method from an interpolation and log inside it. Count how many times it runs when you type in an unrelated input.
- Precompute the value instead and compare.
- Have the child mutate
student.statusdirectly. Confirm the parent's data changed, then convert it to an output. - Convert the component to signals with
signal,computedandinput.required. Compare with the property version. - Add
computedfor a filtered list and confirm it recalculates only when a dependency changes. - Add
ChangeDetectionStrategy.OnPush. Update the array withpushand confirm the view does not change. Fix it with a spread. - Access a
@ViewChildinngOnInitand recordundefined. Move it tongAfterViewInit. - Set a property in
ngAfterViewInitthat the template reads. RecordNG0100, then fix it. - 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
@Inputand out with@Output - Say why a child must not mutate an
@Input - Read a template compile error
Review questions
- Why does
disabled="false"disable a button? - Why is
[attr.aria-label]needed rather than[aria-label]? - What must change for an
OnPushcomponent to re-render? - Why is
computed()better than a getter called from a template?
Next: Directives and pipes