Fast jede App hat eine Liste, die man durchsuchen, filtern, sortieren und durchblättern kann. Mit Signals sind das ein paar Zeilen: Die Filter sind Signals, und die Liste auf dem Bildschirm ist ein computed, das daraus gebaut wird. Spannend ist, wie man dieses computed aufteilt.
Diese Seite baut Schritt für Schritt einen Katalog: eine Kette von computed-Signals, die nur neu berechnet, was sich geändert hat, linkedSignal, um die Seite zurückzusetzen, und Filter in der URL, damit ein Link oder ein Reload dieselbe Ansicht zurückbringt.
Der Zustand: ein paar Signals
Jedes Steuerelement schreibt ein Signal: query, category, sort, page. Sonst wird nichts gespeichert — die sichtbare Liste ist immer abgeleitet und kann sich daher nie von den Filtern entfernen.
export class Catalog {
private readonly products = inject(ProductStore).all; // Signal<Product[]>
// ein Signal pro Steuerelement
readonly query = signal('');
readonly category = signal<Category | 'all'>('all');
readonly sort = signal<Sort>('relevance');
readonly page = signal(1);
}Eine Kette von computed-Signals
Pack jeden Schritt in ein eigenes computed: filtered liest die Daten und die Filter, sorted liest filtered und die Sortierung, visible liest sorted und die Seite. Ein computed läuft nur wieder, wenn sich etwas geändert hat, das es gelesen hat — Umblättern führt also nur den Ausschnitt neu aus.
// jeder Schritt liest nur, was er braucht — und läuft nur neu, wenn sich das ändert
readonly filtered = computed(() => {
const q = fold(this.query().trim());
const cat = this.category();
return this.products().filter(
(p) => (cat === 'all' || p.category === cat) && fold(p.name).includes(q),
);
});
readonly sorted = computed(() =>
this.filtered().toSorted(comparator(this.sort())), // eine Kopie: nie das Array des Signals sortieren
);
readonly visible = computed(() => {
const start = (this.page() - 1) * PAGE_SIZE;
return this.sorted().slice(start, start + PAGE_SIZE); // die Seite ändert sich → nur das hier läuft
});
readonly pages = computed(() => Math.max(1, Math.ceil(this.sorted().length / PAGE_SIZE)));| TelefonTelefone & Tablets | 351,99 € | ★ 2.6 |
| Telefon MiniTelefone & Tablets | 509,99 € | ★ 2.7 |
| Telefon AirTelefone & Tablets | 552,99 € | ★ 4.3 |
| Telefon ProTelefone & Tablets | 920,99 € | ★ 4.6 |
| Telefon MaxTelefone & Tablets | 778,99 € | ★ 4.8 |
| TabletTelefone & Tablets | 377,99 € | ★ 3.9 |
| Schritt | Läufe | Letzter Lauf |
|---|---|---|
filtered | 1 | 65 Produkte geprüft |
sorted | 1 | 64 Vergleiche |
visible | 1 | 6 Einträge genommen |
In der Kette führen die Blätter-Buttons nur visible aus; Sortieren führt sorted und visible aus; nur die Filter erreichen filtered. In einem großen computed filtert und sortiert jeder Klick alle 65 Produkte neu. Auch die Reihenfolge zählt: zuerst filtern (weniger zu sortieren), zuletzt ausschneiden. Und es läuft nur die Form, die auf dem Bildschirm ist: Ein computed, das niemand liest, wird nie ausgeführt. Tippst du schnell, siehst du vielleicht weniger Läufe als Tastendrücke: Ein computed rechnet nur neu, wenn es gelesen wird, und Angular liest es einmal pro Rendering.
Zurück auf Seite 1, wenn sich die Filter ändern
Bist du auf Seite 5 und suchst etwas mit zwei Seiten Ergebnissen, landest du auf „Seite 5 von 2“. Genau dafür ist linkedSignal da: ein beschreibbares Signal, das sich selbst zurücksetzt, sobald sich seine Quelle ändert — hier jeder Filter oder die Sortierung. Siehe linkedSignal.
// ein beschreibbares Signal, das auf 1 zurückgeht, wenn sich ein Filter oder die Sortierung ändert
readonly page = linkedSignal({
source: () => [this.query(), this.category(), this.sort()],
computation: () => 1,
});
next() {
this.page.update((p) => Math.min(p + 1, this.pages()));
}Eine Suche, die sich richtig anfühlt
Vergleiche normalisierten Text, kleingeschrieben und ohne diakritische Zeichen (normalize('NFD')), damit „kopfhorer“ „Kopfhörer“ findet. Sortiere Namen mit localeCompare in der Sprache des Nutzers. Bei Suchen, die zum Server gehen, warte mit debounced() auf eine Tipppause.
/** kleingeschrieben, ohne diakritische Zeichen: "kopfhorer" findet "Kopfhörer", "cafe" findet "Café" */
export function fold(text: string): string {
return text.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
}
// Namen so sortiert, wie der Nutzer es in seiner Sprache erwartet
const byName = (a: Product, b: Product) => a.name.localeCompare(b.name, locale);Rendering: track nach id
@for (p of visible(); track p.id) lässt Angular bestehende Zeilen verschieben, statt sie neu zu erzeugen, wenn sich die Reihenfolge ändert. Mit track $index würde nach jedem Sortieren jede Zeile neu gerendert. Mehr unter Control Flow.
<input type="search" [value]="query()" (input)="query.set(search.value)" #search />
@for (p of visible(); track p.id) {
<app-product-row [product]="p" />
} @empty {
<p>Keine passenden Produkte.</p>
}
<button (click)="page.set(page() - 1)" [disabled]="page() === 1">Zurück</button>
<span>Seite {{ page() }} von {{ pages() }}</span>
<button (click)="page.set(page() + 1)" [disabled]="page() === pages()">Weiter</button>Filter in der URL
Filter, die nur in Signals liegen, verschwinden beim Reload und lassen sich nicht teilen. Leg sie in den Query-String — /catalog?q=pro&cat=audio — und die URL wird zum Zustand: Ein Link öffnet dieselbe Ansicht, und Zurück kehrt zur vorigen Ansicht zurück, statt die Seite zu verlassen.
65 Ergebnisse · Telefon, Telefon Mini, Telefon Air
- /catalog
angularatlas.dev/catalog
Wer diesen Link öffnet, sieht genau das, was im Link steht — nicht mehr.
Nur mit Signals verlässt Zurück den Katalog, und Neu laden leert ihn. Jede Änderung in den Verlauf zu schieben behebt das, macht Zurück aber zu einem Rückgängig für jeden Tastendruck. Der übliche Kompromiss: den aktuellen Verlaufseintrag ersetzen, während der Nutzer tippt, und für klare Entscheidungen wie eine Kategorie einen neuen hinzufügen.
// app.config.ts: provideRouter(routes, withComponentInputBinding())
export class Catalog {
private readonly router = inject(Router);
// ?q=…&cat=… kommen als Inputs an; die URL ist die Quelle der Wahrheit
readonly q = input('');
readonly cat = input<Category | 'all'>('all');
search(text: string) {
// Tippen: den aktuellen Verlaufseintrag ersetzen, nicht einen pro Taste hinzufügen
this.update({ q: text || null }, true);
}
pickCategory(cat: Category | 'all') {
this.update({ cat: cat === 'all' ? null : cat }, false); // ein echter Schritt: Zurück kehrt hierher zurück
}
private update(queryParams: Params, replaceUrl: boolean) {
// null entfernt einen Parameter: Standardwerte bleiben aus der URL
this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl });
}
}Mit withComponentInputBinding() kommen Query-Parameter als Inputs der Komponente an; zurückgeschrieben werden sie mit router.navigate() und queryParamsHandling: 'merge'. Halte die URL kurz, indem du Standardwerte weglässt, wie es das Lab tut.
Wenn die Liste riesig wird
Ab ein paar tausend Zeilen ist das Rendern der Engpass, nicht das Filtern: Rendere mit Virtual Scroll nur, was auf dem Bildschirm ist. Liegen die Daten auf dem Server, schick die Filter als Request-Parameter und lass httpResource neu laden, wenn sie sich ändern.
Häufige Stolperfallen
- Das Array des Signals an Ort und Stelle sortieren.
list.sort()verändert es. Kopiere zuerst ([...list]odertoSorted()), sonst arbeitet der nächste Filter auf einer umsortierten Quelle. - Die gefilterte Liste in einem Signal speichern. Dann muss jedes Steuerelement daran denken, sie zu aktualisieren. Leite sie mit
computedab. - „Seite 5 von 2“. Die Seite wurde nicht zurückgesetzt, als sich die Filter änderten. Nimm
linkedSignal. - Ein Verlaufseintrag pro Tastendruck. Nimm
replaceUrl: true, solange der Nutzer tippt. track $indexbei einer sortierbaren Liste. Jedes Sortieren rendert jede Zeile neu und verliert Fokus und Eingaben.
Spickzettel
Zustand ein Signal pro Steuerelement: query, category, sort, page
abgeleitet filtered → sorted → visible, jedes ein eigenes computed()
Reihenfolge zuerst filtern, weniger sortieren, zuletzt ausschneiden
lazy ein computed, das niemand liest, läuft nie
Seite zurücksetzen linkedSignal({ source: () => [Filter…], computation: () => 1 })
nie verändern toSorted() / [...list].sort(); das Array des Signals bleibt, wie es war
Suche normalize('NFD') + Kleinbuchstaben; localeCompare(b, locale) für Namen
Rendering @for (… ; track p.id), nie track $index bei einer sortierbaren Liste
URL ?q=&cat=&sort=; withComponentInputBinding(); Standardwerte weglassen
Verlauf replaceUrl beim Tippen, push für echte Schritte (Kategorie, Seite)
riesige Listen Virtual Scroll fürs Rendering; httpResource, wenn der Server filtert