Master Angular signals — signal for state, immutable updates, computed values, effects, two-way binding with model, element access with viewChild, and lifecycle hooks.
Why: inputs come from the parent and are read-only; state is owned by the component and changes over time. You write state as count = signal(initialValue) — read it by calling count(), replace it with .set(v), or compute the next value from the previous with .update(fn). Setting a signal re-renders what uses it — that is the entire Angular update model.
import { Component, input, signal } from '@angular/core'
@Component({
selector: 'app-counter',
template: `
<!-- Setting a signal re-renders what uses it -->
<button (click)="increment()">Count: {{ count() }}</button>
`,
})
export class Counter {
step = input(1) // an input (passed in, read-only)
count = signal(0) // state (owned here, changes over time)
increment() {
// .set replaces; .update computes the next value from the previous
this.count.update((c) => c + this.step())
}
}Why: a signal only announces a change when it receives a NEW value — it compares by reference, "is this the same one as before?". If you push into the existing array or change a field on the existing object, the signal thinks nothing changed and the screen stays stale. So always build a new array or object, usually by spreading the old one inside .update().
import { Component, signal } from '@angular/core'
@Component({
selector: 'app-todos',
template: `
<button (click)="addTodo()">Add</button>
<button (click)="birthday()">{{ user().name }} is {{ user().age }}</button>
<p>{{ todos().join(', ') }}</p>
`,
})
export class Todos {
todos = signal<string[]>([])
user = signal({ name: 'Ada', age: 36 })
addTodo() {
// Never push into the existing array — create a NEW one
this.todos.update((list) => [...list, 'Todo #' + (list.length + 1)])
}
birthday() {
// Same for objects: spread, then override
this.user.update((u) => ({ ...u, age: u.age + 1 }))
}
}Why: a computed is a signal calculated from other signals — it recalculates only when something it reads changes, and caches the result in between. Reach for it whenever one value can be derived from another instead of storing both.
import { Component, computed, signal } from '@angular/core'
@Component({
selector: 'app-product-list',
template: `
<input [value]="query()" (input)="onInput($event)" placeholder="Search…" />
<p>{{ filtered().length }} result(s): {{ filtered().join(', ') }}</p>
`,
})
export class ProductList {
query = signal('')
products = signal(['Laptop', 'Phone', 'Tablet'])
// Recalculates only when query or products change — and caches the result
filtered = computed(() =>
this.products().filter((p) =>
p.toLowerCase().includes(this.query().toLowerCase()),
),
)
onInput(e: Event) {
this.query.set((e.target as HTMLInputElement).value)
}
}Why: effects run side effects when signals change — log, save to storage, sync something outside Angular. effect(fn) runs once, then again whenever a signal it READS changes; create it in the constructor. When: reach for computed first; an effect is for talking to the outside world, not for deriving values.
import { Component, effect, signal } from '@angular/core'
@Component({
selector: 'app-theme',
template: `<button (click)="toggle()">Theme: {{ theme() }}</button>`,
})
export class Theme {
theme = signal<'light' | 'dark'>('light')
constructor() {
// Runs once, then again whenever theme() changes
effect(() => {
console.log('theme is now', this.theme())
})
}
toggle() {
this.theme.update((t) => (t === 'light' ? 'dark' : 'light'))
}
}Why: the input-down/output-up dance is so common that Angular gives it one line: model() creates an input the child can also WRITE, and the parent connects with [(banana-in-a-box)] syntax — changes flow both ways.
import { Component, model, signal } from '@angular/core'
@Component({
selector: 'app-search-input',
template: `<input [value]="value()" (input)="onInput($event)" />`,
})
export class SearchInput {
// model() = an input the child may write back to the parent
value = model('')
onInput(e: Event) {
this.value.set((e.target as HTMLInputElement).value)
}
}
@Component({
selector: 'app-search-page',
imports: [SearchInput],
template: `
<!-- [( )] = two-way binding to the model -->
<app-search-input [(value)]="query" />
<p>Searching for: {{ query() }}</p>
`,
})
export class SearchPage {
query = signal('')
}Why: sometimes you need the real element on the page — to focus an input, scroll, or measure it. Name the element with #name in the template and viewChild hands you an ElementRef whose .nativeElement is the element itself.
import { Component, ElementRef, viewChild } from '@angular/core'
@Component({
selector: 'app-search-bar',
template: `
<!-- #search names the element so the class can find it -->
<input #search placeholder="Search…" />
<button (click)="focusInput()">Focus input</button>
`,
})
export class SearchBar {
// viewChild = direct access to a real element in this template
searchEl = viewChild.required<ElementRef<HTMLInputElement>>('search')
focusInput() {
this.searchEl().nativeElement.focus()
}
}Why: lifecycle hooks connect your component to things Angular does not control — timers, subscriptions, browser APIs. ngOnInit runs once the component is up; ngOnDestroy runs when it leaves the screen — undo there whatever you started.
import { Component, OnDestroy, OnInit, signal } from '@angular/core'
@Component({
selector: 'app-clock',
template: `<p>{{ now()?.toLocaleTimeString() ?? 'Loading…' }}</p>`,
})
export class Clock implements OnInit, OnDestroy {
// null until the first tick — that's why the type is Date | null
now = signal<Date | null>(null)
private id?: ReturnType<typeof setInterval>
// Runs once the component is up — start timers and subscriptions here
ngOnInit() {
this.id = setInterval(() => this.now.set(new Date()), 1000)
}
// Runs when the component leaves the screen — clean up what you started
ngOnDestroy() {
clearInterval(this.id)
}
}