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 build exit code 0, ng test --no-watch exit code 0 (2 files, 2 tests passed, Vitest 5.1.0); with .startsWith instead of .includes in the filter, exit code 1. The snippets on effect and linkedSignal compiled in the class of the next lesson (ng build, exit code 0; the tests ran green with the effect); they are not part of the solution. Also run: a missing imports (NG8001) and a missing track (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):

TypeScript
@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:

TypeScript
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):

TypeScript
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:

TypeScript
protected readonly ersterTreffer = linkedSignal(() => this.treffer()[0]);

Control flow in the template

The template has built-in blocks (page "Control flow"):

HTML
@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 }
}
  • @if has @else if and @else; @if (expression; as name) puts the result into a variable.
  • @for needs track (without it the build stops with NG5002: @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 (here id). $index is only suitable for lists that never change, and the entry itself as the key is, according to the page, the last resort. @empty runs for an empty collection; continue and break do not exist.
  • @switch compares with ===, without fall-through; consecutive @case blocks 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:

TypeScript
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:

  1. Create src/app/kontakte/contact-summary.ts: an interface ContactSummary with id: number, name, company and tags: string[] (those are the names of the API's fields; check the K2 state to see whether id is a number in yours).
  2. Generate the service KontaktDaten (ng generate service kontakte/kontakt-daten) with a method liste() that returns four invented contacts.
  3. Write the component KontaktListe: a signal kontakte from the service, a signal filter, a computed treffer (filter by name or company, regardless of case), a signal sortierung (name or company) with a button to switch it.
  4. The template: heading, labeled field, a "Clear filter" button only when the filter is not empty, the list with @for and @empty, the sorting with @switch in the button's label text.
  5. Hang <kl-kontakt-liste /> in app.html and KontaktListe in imports of App. Replace the check in app.spec.ts (Hello, kladde-angular, 0101) with expect(el.querySelectorAll('li').length).toBe(4). Build and start npx 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:

TypeScript
export interface ContactSummary {
  id: number;
  name: string;
  company: string;
  tags: string[];
}

src/app/kontakte/kontakt-daten.ts (names and companies are invented):

TypeScript
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:

TypeScript
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:

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: KontaktListe is missing from imports of App.
  • The list does not change while typing: filter is not a signal, or filterGeaendert does not set it.
  • NG5002: @for without track.

What you can do now

  • You write a component, enter others in imports and bind text, properties, attributes and events.
  • You keep state in signal, derive it with computed, use effect only for reactions to the outside and know the difference from linkedSignal.
  • You steer the template with @if, @for (always with a unique track) and @switch and fetch services with inject().
  • 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

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).