An Angular screen consists of components: a class for the state, a template (HTML) for the presentation and a stylesheet. The state lives in signals; when they change, Angular updates
exactly the places in the template that read them. This lesson uses them to build the first Kladde page: the contact list from fixed data with a filter field. The API comes in 0202. You work in the project from
0101 (web/kladde-angular, prefix kl).
State of the checks. The code of the lesson (service, component, template; plus one test each for the list and the root, not printed here, 0401) ran in a project with Angular 22.2, TypeScript 6.0.3 and Node 24.22.3:
ng buildexit code 0,ng test --no-watchexit code 0 (2 files, 2 tests passed, Vitest 5.1.0); with.startsWithinstead of.includesin the filter, exit code 1. The snippets oneffectandlinkedSignalcompiled in the class of the next lesson (ng build, exit code 0; the tests ran green with theeffect); they are not part of the solution. Also run: a missingimports(NG8001) and a missingtrack(NG5002) broke the build with exit code 1. Not run: a browser, a screen reader, operation with the keyboard; the sentences about them are inferences from the HTML, not observations. Statements about Angular come from angular.dev (pages on components, bindings, signals, control flow, zoneless, style guide, read on the day of writing); quotations are in English in the original.
The component
The head of the component KontaktListe (from ng generate component kontakte/kontakt-liste, 0101):
@Component({
imports: [],
selector: 'kl-kontakt-liste',
styleUrl: './kontakt-liste.scss',
templateUrl: './kontakt-liste.html',
})
export class KontaktListe {}
The selector is the element name in other templates: <kl-kontakt-liste />. templateUrl and styleUrl point to files next to the class ("relative to the directory in which the component resides");
short templates may go into the class with template:. Components are standalone: anyone who uses another component in their template enters it in imports. The "Components" page says: "In Angular versions
before 19.0.0, the standalone option defaults to false"; today you do not have to write anything for that, and the CLI generates no NgModules. The root App (0101) pulls the list into its template with imports: [KontaktListe]. If you forget the entry in imports, the build stops with NG8001: 'kl-kontakt-liste' is not a known element (exit code 1); the error comes before the browser.
Bindings
The template is HTML with four additions (page "Binding dynamic text, properties and attributes"):
| Notation | Direction | Example |
|---|---|---|
{{ expression }} |
class to text | {{ kontakt.name }} |
[property]="expression" |
class to a property of the element or the component | [value]="filter()" |
[attr.name]="expression" |
class to an attribute; null or undefined removes it |
[attr.aria-label]="beschriftung()" |
(event)="call" |
element to class | (input)="filterGeaendert($event)" |
On {{ }} the page says: "Expression values are converted to a string" and "null and undefined render as an empty string". [class.aktiv]="expression" switches a class, [style.height.px]="expression" a
style. The template can only read what the class makes visible: the Style Guide recommends protected for members that only the template needs, and readonly for what does not change.
Signals: signal, computed, effect, linkedSignal
According to the "Signals" page, a signal is "a wrapper around a value that notifies interested consumers when that value changes". You read it by calling it:
const anzahl = signal(0);
console.log(anzahl()); // read
anzahl.set(3); // replace
anzahl.update((v) => v + 1); // compute from the old value
computed derives a value from other signals: const doppelt = computed(() => anzahl() * 2). The page stresses that a computed is lazy and memoized (the derivation runs on the first read,
after that the value comes from the cache until a dependency changes), that it is read-only (doppelt.set(3) is a compile error) and that you may "safely perform computationally
expensive derivations … such as filtering arrays" in it. Only signals that are actually read during the calculation count as dependencies. For the Kladde: the filter is a signal, the visible list a
computed from contacts, filter and sorting.
effect runs code when signals that it reads change. The page says to use it "for those specific use cases" in which you react to changes and address a program interface that knows nothing about
signals; derivations do not belong in it ("Synchronous or asynchronous derivations are recommended when we want to react to state changes": that is, computed or resource). One defensible case in the Kladde is mirroring the filter in the browser's sessionStorage (in the constructor or a field of the class):
constructor() {
effect(() => sessionStorage.setItem('kl-filter', this.filter()));
}
The rule of thumb: a value from values is computed; a reaction to the outside is effect.
linkedSignal is a writable signal that depends on others. The page "Dependent state with linkedSignal": "The linkedSignal function lets you create a signal to hold some state that is intrinsically
linked to some other state." Its example is a selection that falls back to the first entry when the list changes; for the Kladde:
protected readonly ersterTreffer = linkedSignal(() => this.treffer()[0]);
Control flow in the template
The template has built-in blocks (page "Control flow"):
@if (filter() !== '') {
<button type="button" (click)="filter.set('')">Clear filter</button>
}
@for (kontakt of treffer(); track kontakt.id) {
<li>{{ kontakt.name }}</li>
} @empty {
<li>No contacts found.</li>
}
@switch (sortierung()) {
@case ('name') { Name }
@case ('company') { Company }
}
@ifhas@else ifand@else;@if (expression; as name)puts the result into a variable.@forneedstrack(without it the build stops withNG5002: @for loop must have a "track" expression): the expression "allows Angular to maintain a relationship between your data and the DOM nodes". As the page advises, take a property that uniquely identifies each entry (hereid).$indexis only suitable for lists that never change, and the entry itself as the key is, according to the page, the last resort.@emptyruns for an empty collection;continueandbreakdo not exist.@switchcompares with===, without fall-through; consecutive@caseblocks share a block.
Services with inject()
A service is a class with a task of its own that components fetch. The CLI generates it with @Service() (0101); the Style Guide recommends fetching it with the function inject instead of through constructor parameters:
private readonly daten = inject(KontaktDaten);
protected readonly kontakte = signal(this.daten.liste());
For you as a .NET developer: this corresponds to constructor injection, with
a singleton at application scope as the default (according to the page, @Service provides the class "at the root injector").
OnPush and zoneless
Two terms from Angular texts and what they mean for your code. The page "Skipping component subtrees" says: "OnPush is the default change detection strategy in Angular (since v22)"; an
OnPush component is only checked when an input changes, an event occurs in it, it is explicitly marked for checking or a signal that its template reads changes. Zoneless (page
"Zoneless", the default since version 21) means: Angular relies on exactly these notifications and not on the library zone.js. Both fit signals. Your rule for the course: keep state in signals,
write nothing into class fields that the template reads without it being a signal. Then the display updates itself.
Accessibility on this page
The filter field has a <label for> with the matching id: a placeholder is not a label.
A screen reader does not yet learn that the number of hits changes while you type; 0103 solves that with aria-live.
Exercise 1
The contact list from fixed data with a filter field. In the project from 0101:
- Create
src/app/kontakte/contact-summary.ts: aninterface ContactSummarywithid: number,name,companyandtags: string[](those are the names of the API's fields; check the K2 state to see whetheridis a number in yours). - Generate the service
KontaktDaten(ng generate service kontakte/kontakt-daten) with a methodliste()that returns four invented contacts. - Write the component
KontaktListe: a signalkontaktefrom the service, a signalfilter, acomputedtreffer(filter by name or company, regardless of case), a signalsortierung(name or company) with a button to switch it. - The template: heading, labeled field, a "Clear filter" button only when the filter is not empty, the list with
@forand@empty, the sorting with@switchin the button's label text. - Hang
<kl-kontakt-liste />inapp.htmlandKontaktListeinimportsofApp. Replace the check inapp.spec.ts(Hello, kladde-angular, 0101) withexpect(el.querySelectorAll('li').length).toBe(4). Build and startnpx ng serve(you do not need the API here).
How to check: four rows appear; "muster" leaves two (case does not matter), "zzz" shows "No contacts found."; the "Clear filter" button appears and disappears; the sorting switches.
npx ng build and npx ng test --no-watch end with 0. Operate everything with just Tab, Space and Enter.
Solution 1
src/app/kontakte/contact-summary.ts:
export interface ContactSummary {
id: number;
name: string;
company: string;
tags: string[];
}
src/app/kontakte/kontakt-daten.ts (names and companies are invented):
import { Service } from '@angular/core';
import { ContactSummary } from './contact-summary';
@Service()
export class KontaktDaten {
liste(): ContactSummary[] {
return [
{ id: 1, name: 'Anna Beispiel', company: 'Beispiel AG', tags: ['kunde'] },
{ id: 2, name: 'Ben Muster', company: 'Muster GmbH', tags: ['kunde', 'wichtig'] },
{ id: 3, name: 'Clara Probst', company: 'Probst & Söhne', tags: [] },
{ id: 4, name: 'Dario Test', company: 'Muster GmbH', tags: ['lieferant'] },
];
}
}
src/app/kontakte/kontakt-liste/kontakt-liste.ts:
import { Component, computed, inject, signal } from '@angular/core';
import { KontaktDaten } from '../kontakt-daten';
@Component({
imports: [],
selector: 'kl-kontakt-liste',
styleUrl: './kontakt-liste.scss',
templateUrl: './kontakt-liste.html',
})
export class KontaktListe {
private readonly daten = inject(KontaktDaten);
protected readonly kontakte = signal(this.daten.liste());
protected readonly filter = signal('');
protected readonly sortierung = signal<'name' | 'company'>('name');
protected readonly treffer = computed(() => {
const suche = this.filter().trim().toLowerCase();
const feld = this.sortierung();
return this.kontakte()
.filter((k) => `${k.name} ${k.company}`.toLowerCase().includes(suche))
.sort((a, b) => a[feld].localeCompare(b[feld], 'de'));
});
protected filterGeaendert(ereignis: Event): void {
this.filter.set((ereignis.target as HTMLInputElement).value);
}
protected sortierungWechseln(): void {
this.sortierung.update((alt) => (alt === 'name' ? 'company' : 'name'));
}
}
src/app/kontakte/kontakt-liste/kontakt-liste.html:
<h1>Contacts</h1>
<label for="kontakt-filter">Filter by name or company</label>
<input id="kontakt-filter" type="search" [value]="filter()" (input)="filterGeaendert($event)" />
@if (filter() !== '') {
<button type="button" (click)="filter.set('')">Clear filter</button>
}
<button type="button" (click)="sortierungWechseln()">
Sorted by:
@switch (sortierung()) {
@case ('name') { Name }
@case ('company') { Company }
}
</button>
<ul>
@for (kontakt of treffer(); track kontakt.id) {
<li>{{ kontakt.name }} ({{ kontakt.company }})</li>
} @empty {
<li>No contacts found.</li>
}
</ul>
Why it is written this way: [value]="filter()" keeps the field empty after "Clear filter". .sort changes the
array that .filter has just created, and not kontakte(). Typical mistakes:
NG8001: 'kl-kontakt-liste' is not a known element:KontaktListeis missing fromimportsofApp.- The list does not change while typing:
filteris not a signal, orfilterGeaendertdoes not set it. NG5002:@forwithouttrack.
What you can do now
- You write a component, enter others in
importsand bind text, properties, attributes and events. - You keep state in
signal, derive it withcomputed, useeffectonly for reactions to the outside and know the difference fromlinkedSignal. - You steer the template with
@if,@for(always with a uniquetrack) and@switchand fetch services withinject(). - You know what OnPush and zoneless mean for your code: state belongs in signals.
- You have built the contact list from fixed data with a filter field.
Further reading
- Components: selector,
imports, template and styles. - Binding dynamic text, properties and attributes:
{{ }},[ ],[attr.], classes and styles. - Signals:
signal,computed,effect. - Dependent state with linkedSignal: writable, dependent state.
- Control flow:
@if,@for,@switchandtrack. - Creating and using services:
@Service,inject. - Skipping component subtrees: when an OnPush component is checked.
The Kladde: src/app/kontakte holds contact-summary.ts, kontakt-daten.ts and the component kontakt-liste with a test. Next you split it into building blocks of its own with inputs and outputs (0103).