Signal Queries sind die funktionalen Pendants zu den klassischen Decoratoren @ViewChild, @ViewChildren, @ContentChild und @ContentChildren. Statt eine Klassen-Property nach dem ersten Render zu befüllen, geben dir viewChild(), viewChildren(), contentChild() und contentChildren() ein Signal zurück — initial undefined oder ein leeres Array, danach automatisch der aktuelle Wert. Damit fällt der gesamte Lifecycle-Hook-Tanz um ngAfterViewInit weg, und Queries werden zu echten reaktiven Quellen, die sich nahtlos in computed() und effect() einklinken.
Dieser Artikel zeigt mit Angular 21, wie die vier Funktionen aufgebaut sind, wie die required-Variante das | undefined aus dem Type schneidet, wann read den Token-Typ wechselt, warum descendants: true für Content-Queries oft entscheidend ist und wie eine vollständige Tabs-Component mit contentChildren + computed aussieht. Signal Queries sind seit v17.2 stable, in v19 wurde der Initializer-API-Stempel gesetzt — kein Devkit-Flag mehr nötig.
Funktionale Queries als Signal
Klassische Queries arbeiten mit einem Decorator auf einer Klassen-Property: @ViewChild('foo') foo!: ElementRef. Angular füllt diese Property erst im Lifecycle-Hook ngAfterViewInit (View-Queries) bzw. ngAfterContentInit (Content-Queries). Vor diesem Hook ist die Property undefined — was zu einer ganzen Klasse von Stolpersteinen führt: Konstruktoren, die zu früh greifen, effect()-Aufrufe, die das Resultat verfehlen, und BehaviorSubject-Brücken, die das Timing-Problem manuell überspielen.
Signal Queries lösen das mit einem anderen Ansatz: Statt einer Property liefert dir die Initializer-Funktion ein Signal. Der Wert ist anfänglich undefined (Single) oder [] (List) und wird von Angular automatisch aktualisiert, sobald das Element im View- oder Content-Baum auftaucht, verschwindet oder sich ändert.
import { Component, viewChild, effect } from '@angular/core';
import { AlertComponent } from './alert.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [AlertComponent],
template: `<app-alert />`,
})
export class DashboardComponent {
// Signal<AlertComponent | undefined>
alert = viewChild(AlertComponent);
constructor() {
effect(() => {
const cmp = this.alert();
if (cmp) {
console.log('Alert ist da:', cmp);
}
});
}
}Drei Eigenschaften unterscheiden Signal-Queries grundsätzlich von Decorator-Queries:
- Reaktiv von Anfang an. Du brauchst keinen Lifecycle-Hook mehr —
effect()undcomputed()reagieren automatisch, sobald der Wert auflöst. - Type ist explizit.
viewChild(X)returntSignal<X | undefined>,viewChild.required(X)returntSignal<X>— kein!-Hack auf der Property. - List statt QueryList.
viewChildren()undcontentChildren()gebenSignal<readonly T[]>zurück. Kein.subscribe()aufQueryList.changes, kein Cleanup.
viewChild, viewChildren, contentChild, contentChildren
Alle vier Funktionen folgen demselben Muster: Sie nehmen einen Locator (Component-Klasse, Template-Reference-String oder DI-Token) und optional ein Options-Object. Sie unterscheiden sich nur in zwei Achsen — Single vs. List und View vs. Content.
| Funktion | Suchraum | Rückgabe | Decorator-Pendant |
|---|---|---|---|
viewChild() | View | Signal<T | undefined> | @ViewChild |
viewChild.required() | View | Signal<T> | @ViewChild + ! |
viewChildren() | View | Signal<readonly T[]> | @ViewChildren |
contentChild() | Projektion | Signal<T | undefined> | @ContentChild |
contentChild.required() | Projektion | Signal<T> | @ContentChild + ! |
contentChildren() | Projektion | Signal<readonly T[]> | @ContentChildren |
View meint hier den eigenen Template-Block der Component. Content meint Markup, das von außen über <ng-content> projiziert wurde. Ein und dieselbe Komponente kann beide Arten gleichzeitig haben — eine Tabs-Komponente etwa hat ein eigenes Header-Template (View) und nimmt einzelne Tab-Elemente projiziert auf (Content).
Grundsyntax mit drei Locator-Arten
viewChild() akzeptiert drei Arten von Locator: eine Component-Klasse, einen Template-Reference-Variablen-String (wie 'myInput' für #myInput) oder ein DI-Token wie ElementRef oder TemplateRef.
import { Component, viewChild, ElementRef, TemplateRef } from '@angular/core';
import { AlertComponent } from './alert.component';
@Component({
selector: 'app-locators',
standalone: true,
imports: [AlertComponent],
template: `
<app-alert />
<input #email type="email" />
<ng-template #row let-name>
<p>Hallo, {{ name }}</p>
</ng-template>
`,
})
export class LocatorsComponent {
// 1) Component-Klasse als Locator: Type wird automatisch inferiert
alert = viewChild(AlertComponent);
// Signal<AlertComponent | undefined>
// 2) Template-Reference-String + explizites Generic
email = viewChild<ElementRef<HTMLInputElement>>('email');
// Signal<ElementRef<HTMLInputElement> | undefined>
// 3) Template-Reference auf <ng-template> mit TemplateRef-Type
row = viewChild<TemplateRef<{ $implicit: string }>>('row');
// Signal<TemplateRef<...> | undefined>
}Der Wert wird durch Aufruf der Signal-Funktion gelesen: this.alert() returnt AlertComponent | undefined. Ohne die Klammern bekommst du die Signal-Funktion selbst — ein häufiger Fehler in Templates und Effects, der zu still falschen Wahrheits-Checks führt (eine Funktion ist immer truthy).
Wenn das Element garantiert existiert
Liegt das Ziel nicht hinter einem @if oder *ngIf und ist es kein konditional projiziertes Element, kannst du viewChild.required() (oder contentChild.required()) verwenden. Der Type verliert dann das | undefined, und Angular wirft einen klaren Runtime-Error, falls das Element doch fehlt.
import { Component, viewChild, ElementRef } from '@angular/core';
@Component({
selector: 'app-canvas-host',
standalone: true,
template: `<canvas #board width="400" height="200"></canvas>`,
})
export class CanvasHostComponent {
// Signal<ElementRef<HTMLCanvasElement>> — kein undefined!
board = viewChild.required<ElementRef<HTMLCanvasElement>>('board');
draw() {
// Direkter Zugriff ohne Optional-Chaining
const ctx = this.board().nativeElement.getContext('2d')!;
ctx.fillRect(10, 10, 80, 40);
}
}required heißt im TypeScript-Type non-nullable, nicht „Angular garantiert die Existenz”. Wenn das Element zur Laufzeit doch fehlt (z. B. weil ein dynamisch eingehängtes Template es weglässt), wirft Angular beim ersten ()-Aufruf einen NG0951-Error. Der Schutz ist also Type-Schutz plus Fail-Fast — kein magischer DOM-Zwang.
Reactivity bei Add, Remove, Move
viewChildren() returnt Signal<readonly T[]>. Das Array aktualisiert sich automatisch, wenn neue Elemente erscheinen, alte verschwinden oder die Reihenfolge sich ändert — alles, ohne dass du ein QueryList.changes-Subscribe verdrahten musst.
import { Component, viewChildren, computed, signal } from '@angular/core';
import { ItemComponent } from './item.component';
@Component({
selector: 'app-dynamic-list',
standalone: true,
imports: [ItemComponent],
template: `
<button (click)="add()">Add</button>
<button (click)="remove()">Remove</button>
@for (id of ids(); track id) {
<app-item [id]="id" />
}
<p>Aktuell: {{ count() }} Items, erstes id = {{ firstId() }}</p>
`,
})
export class DynamicListComponent {
ids = signal<number[]>([1, 2, 3]);
// Signal<readonly ItemComponent[]>
items = viewChildren(ItemComponent);
// Reaktive Ableitungen ohne Lifecycle-Hook
count = computed(() => this.items().length);
firstId = computed(() => this.items()[0]?.id ?? null);
add() {
this.ids.update((arr) => [...arr, arr.length + 1]);
}
remove() {
this.ids.update((arr) => arr.slice(0, -1));
}
}Beachte zwei Details: Das Array ist als readonly markiert — du darfst es nicht direkt mutieren (kein push/splice). Und computed() kann das Signal direkt konsumieren; Angular trackt die Abhängigkeit automatisch und re-evaluiert, sobald sich die Liste ändert.
Container-Komponenten und ng-content
Content-Queries durchsuchen das Markup, das von außen in deine Component projiziert wurde — typisch über <ng-content>. View-Queries finden diese Elemente nicht; sie liegen logisch im Parent-Template, nicht in deinem.
import { Component, contentChild, contentChildren } from '@angular/core';
import { PanelComponent } from './panel.component';
@Component({
selector: 'app-accordion',
standalone: true,
template: `
<div class="accordion">
<ng-content />
</div>
<p>Insgesamt {{ panels().length }} Panel(s)</p>
`,
})
export class AccordionComponent {
// Erstes projiziertes Panel — durchsucht ALLE Descendants per Default
firstPanel = contentChild(PanelComponent);
// Alle direkten Panels; tiefer geschachtelt nur mit descendants: true
panels = contentChildren(PanelComponent, { descendants: true });
}Verwendet wird die Component dann von außen so:
<app-accordion>
<app-panel title="Eins">…</app-panel>
<app-panel title="Zwei">…</app-panel>
<app-panel title="Drei">…</app-panel>
</app-accordion>Der entscheidende Unterschied zu View-Queries: Eine Container-Component kennt ihre Inhalte erst zur Laufzeit, weil der Parent sie projiziert. Genau hier glänzt die Signal-Variante — du brauchst keinen ngAfterContentInit-Hook, keinen QueryList.changes.subscribe(). Stattdessen reagiert ein effect() oder computed() automatisch auf jede Änderung.
Vom Component-Tag zum nackten ElementRef
Standardmäßig liefert eine Query auf einem Component-Tag die Component-Instanz. Manchmal willst du aber nur das nackte DOM-Element, den ViewContainerRef oder den TemplateRef desselben Knotens. Die read-Option wechselt das DI-Token:
import {
Component,
viewChild,
ElementRef,
ViewContainerRef,
} from '@angular/core';
import { AlertComponent } from './alert.component';
@Component({
selector: 'app-read-demo',
standalone: true,
imports: [AlertComponent],
template: `
<app-alert #alert />
<ng-container #host></ng-container>
`,
})
export class ReadDemoComponent {
// Component-Instanz (Default)
alertCmp = viewChild.required(AlertComponent);
// Selbe DOM-Stelle, aber als nacktes ElementRef
alertEl = viewChild.required('alert', { read: ElementRef });
// ng-container als programmatischer Insertion-Point
host = viewChild.required('host', { read: ViewContainerRef });
highlight() {
(this.alertEl().nativeElement as HTMLElement).classList.add('on');
}
}Der Type des Signals folgt der read-Angabe: viewChild.required('alert', { read: ElementRef }) gibt Signal<ElementRef>, viewChild('foo', { read: TemplateRef }) gibt Signal<TemplateRef<unknown> | undefined>. Bei der Tabelle weiter unten findest du die häufigsten Read-Token-Wechsel im Praxis-Einsatz.
read-Token | Wann sinnvoll |
|---|---|
ElementRef | DOM-Operationen wie focus(), getBoundingClientRect() |
ViewContainerRef | Programmatisches Einhängen von Templates oder Components |
TemplateRef | <ng-template> als Variable für *ngTemplateOutlet |
| Eigenes DI-Token | Service, der über providers an die Component gehängt ist |
computed() statt ngAfterViewInit
Der eigentliche Mehrwert von Signal-Queries zeigt sich erst, wenn du sie in einer reaktiven Pipeline weiterverarbeitest. Klassische Decorator-Queries sind keine Observables und lassen sich nicht direkt in computed() füttern — du brauchst eine Brücke (z. B. ein BehaviorSubject, das du im ngAfterViewInit befüllst). Signal-Queries sind diese Brücke ab Werk.
import { Component, viewChildren, computed, ElementRef } from '@angular/core';
@Component({
selector: 'app-measure',
standalone: true,
template: `
@for (h of [40, 60, 80]; track h) {
<div #row [style.height.px]="h">Row</div>
}
<p>Erste Höhe: {{ firstHeight() ?? 'n/a' }}</p>
<p>Summe: {{ totalHeight() }}</p>
`,
})
export class MeasureComponent {
rows = viewChildren<ElementRef<HTMLDivElement>>('row');
firstHeight = computed(
() => this.rows()[0]?.nativeElement.clientHeight ?? null,
);
totalHeight = computed(() =>
this.rows().reduce(
(sum, r) => sum + r.nativeElement.clientHeight,
0,
),
);
}Mit klassischen Queries müsstest du diese Berechnungen manuell in ngAfterViewInit anstoßen, in ngAfterViewChecked periodisch refreshen und auf QueryList.changes lauschen. Der Signal-Ansatz reduziert das auf zwei computed()-Definitionen — Reactivity ist ein Sprachmittel, nicht ein Lifecycle-Choreographie-Problem.
Side-by-side mit gleichem Use-Case
Die Migration ist mechanisch einfach, aber ein paar Konventionen ändern sich. Hier dasselbe Beispiel zweimal: links der klassische Decorator-Stil, rechts die Signal-Variante.
// VORHER: Decorator + ngAfterViewInit
import {
Component,
ViewChild,
ViewChildren,
QueryList,
ElementRef,
AfterViewInit,
} from '@angular/core';
import { ItemComponent } from './item.component';
@Component({
selector: 'app-old',
standalone: true,
imports: [ItemComponent],
template: `
<input #email />
<app-item *ngFor="let i of ids" [id]="i" />
`,
})
export class OldComponent implements AfterViewInit {
ids = [1, 2, 3];
@ViewChild('email') emailEl!: ElementRef<HTMLInputElement>;
@ViewChildren(ItemComponent) items!: QueryList<ItemComponent>;
ngAfterViewInit() {
this.emailEl.nativeElement.focus();
this.items.changes.subscribe((q: QueryList<ItemComponent>) => {
console.log('Now', q.length);
});
console.log('Initial', this.items.length);
}
}// NACHHER: Signal-Queries + effect()
import {
Component,
viewChild,
viewChildren,
ElementRef,
effect,
} from '@angular/core';
import { ItemComponent } from './item.component';
@Component({
selector: 'app-new',
standalone: true,
imports: [ItemComponent],
template: `
<input #email />
@for (i of ids; track i) {
<app-item [id]="i" />
}
`,
})
export class NewComponent {
ids = [1, 2, 3];
emailEl = viewChild.required<ElementRef<HTMLInputElement>>('email');
items = viewChildren(ItemComponent);
constructor() {
// Auto-Focus, sobald das Input im DOM ist
effect(() => this.emailEl().nativeElement.focus());
// Reagiert auf jede Listen-Änderung (Add/Remove/Move)
effect(() => console.log('Now', this.items().length));
}
}Drei Konventions-Änderungen lohnt sich zu merken: QueryList<T> wird zu Signal<readonly T[]>, !-Definite-Assertion fällt zu viewChild.required(...), und ngAfterViewInit plus QueryList.changes.subscribe() werden zu effect()-Aufrufen im Konstruktor. Die offiziellen Migration-Schematics laufen über ng generate @angular/core:signal-queries-migration (ab v18) oder das übergreifende signals-Schematic — falls verfügbar; ansonsten ist die manuelle Umstellung in den meisten Komponenten ein Sache von Minuten.
Vollständiges Tabs-Pattern mit contentChildren
Ein kanonischer Anwendungsfall für contentChildren() ist eine Tabs-Komponente: Der Parent projiziert beliebig viele <app-tab>-Elemente; der Wrapper sammelt sie ein und kontrolliert, welcher Tab gerade sichtbar ist. Die ganze State-Logik passt in eine Komponente mit signal() + computed() ohne einen einzigen Lifecycle-Hook.
import { Component, input, TemplateRef, viewChild } from '@angular/core';
@Component({
selector: 'app-tab',
standalone: true,
template: `
<ng-template #content>
<ng-content />
</ng-template>
`,
})
export class TabComponent {
label = input.required<string>();
content = viewChild.required<TemplateRef<unknown>>('content');
}import {
Component,
contentChildren,
signal,
computed,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { TabComponent } from './tab.component';
@Component({
selector: 'app-tabs',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div class="tabs__bar" role="tablist">
@for (tab of tabs(); track tab; let i = $index) {
<button
type="button"
role="tab"
[attr.aria-selected]="i === selectedIndex()"
(click)="selectedIndex.set(i)"
>
{{ tab.label() }}
</button>
}
</div>
<div class="tabs__panel" role="tabpanel">
@if (currentTab(); as tab) {
<ng-container [ngTemplateOutlet]="tab.content()" />
}
</div>
`,
})
export class TabsComponent {
tabs = contentChildren(TabComponent);
selectedIndex = signal(0);
currentTab = computed(() => {
const list = this.tabs();
const idx = this.selectedIndex();
return list[idx] ?? list[0] ?? null;
});
}<app-tabs>
<app-tab label="Profil">
<p>Profil-Daten…</p>
</app-tab>
<app-tab label="Einstellungen">
<p>Einstellungen…</p>
</app-tab>
<app-tab label="Verlauf">
<p>Letzte Aktionen…</p>
</app-tab>
</app-tabs>Drei Beobachtungen: Erstens — tabs() aktualisiert sich automatisch, wenn der Parent dynamisch Tabs hinzufügt oder entfernt (z. B. mit @for). Zweitens — der Tabs-Container braucht keinerlei Lifecycle-Code; alle Beziehungen sind als Signals/Computed ausgedrückt. Drittens — der currentTab-Fallback fängt den Edge-Case ab, dass der selectedIndex über das aktuelle Array hinausläuft, was bei dynamischen Tabs leicht passiert.
viewChild + effect statt ngAfterViewInit
Ein Klassiker, den viele Codebases mit ngAfterViewInit lösen, ist Auto-Focus beim Erscheinen eines Inputs. Mit Signal-Queries reduziert sich das auf einen einzigen effect()-Aufruf:
import { Component, viewChild, ElementRef, effect, signal } from '@angular/core';
@Component({
selector: 'app-auto-focus',
standalone: true,
template: `
<button (click)="show.set(!show())">{{ show() ? 'Hide' : 'Show' }}</button>
@if (show()) {
<input #firstInput placeholder="Auto-fokussiert" />
}
`,
})
export class AutoFocusComponent {
show = signal(false);
// Optional, weil das Input nur bei show() === true im DOM ist
firstInput = viewChild<ElementRef<HTMLInputElement>>('firstInput');
constructor() {
effect(() => {
// Liest beide Signals: das Query-Signal UND show()
const el = this.firstInput()?.nativeElement;
if (el) {
// afterNextRender wäre eine Alternative — hier aber ausreichend
queueMicrotask(() => el.focus());
}
});
}
}Klassisch bräuchtest du @ViewChild('firstInput'), einen AfterViewInit-Hook plus eine separate Reaktion auf jedes Toggle des @if — denn ngAfterViewInit feuert nur einmal beim ersten Render, nicht bei jeder Wiedereinblendung. Der effect() löst beides in einem Schritt: Er feuert sowohl beim ersten Mount als auch bei jedem späteren Re-Mount des Inputs, weil das Query-Signal jeweils neu auflöst.
Was passiert, wenn das Element nur konditional existiert?
Strukturdirektiven (@if, @for, *ngIf, *ngFor und auch eigene *appWhen-Direktiven) hängen ihre Inhalte dynamisch ein und aus. Signal-Queries gehen damit automatisch um:
- Einblenden → das Signal wechselt von
undefinedzu einem gefüllten Wert (Single) oder von[]zu[item](List). - Ausblenden → das Signal wechselt zurück zu
undefinedbzw.[]. - Reihenfolge ändern → bei
viewChildren()/contentChildren()wird das Array neu sortiert; jede Identitäts-vergleichende Logik (z. B.===auf das alte erste Item) muss damit umgehen.
import { Component, viewChild, ElementRef, signal, effect } from '@angular/core';
@Component({
selector: 'app-conditional',
standalone: true,
template: `
<button (click)="visible.set(!visible())">Toggle</button>
@if (visible()) {
<p #para>Hallo</p>
}
`,
})
export class ConditionalComponent {
visible = signal(true);
para = viewChild<ElementRef<HTMLParagraphElement>>('para');
constructor() {
effect(() => {
const el = this.para();
console.log(el ? 'sichtbar:' : 'weg —', el?.nativeElement);
});
}
}viewChild.required('para') wäre hier falsch: Sobald visible auf false springt, wirft Angular beim Lesen einen NG0951-Error. Optional + Null-Check ist die saubere Wahl, sobald ein @if/*ngIf im Spiel ist. Bei @for mit potenziell leerer Liste gilt analog: viewChildren() returnt [], was [0]?.nativeElement korrekt zu undefined auflöst.
Wichtige Details, die in Code-Reviews auffallen
viewChild() ist ein Signal — vergessenes () gibt die Funktion-Reference
Lese-Aufrufe brauchen die Klammern: this.alert(), nicht this.alert. Der zweite Ausdruck gibt die Signal-Funktion selbst — und eine Funktion ist immer truthy, was zu still falschen if (this.alert)-Checks führt. Auch im Template gilt: {{ alert()?.title }}, nicht {{ alert?.title }}.
viewChild.required() wirft RUNTIME-Error, kein Compile-Schutz
Der Type verliert das | undefined, aber Angular garantiert nicht die Existenz im DOM. Ist das Element wegen @if false nicht da, fliegt beim ersten ()-Aufruf ein NG0951. required heißt also: TS-Type non-nullable, plus Fail-Fast. Für konditionale Elemente bleibt die optionale Variante richtig.
Signal-Queries lösen den ngAfterViewInit-Stolperstein
Lese-Aufruf vor dem ersten Render gibt undefined, danach automatisch das Element — kein Hook nötig. Damit darfst du im Konstruktor schon effect(() => …) registrieren, der erst feuert, wenn das Query auflöst. Klassische Decorator-Queries verlangten dafür AfterViewInit plus Brückenlogik.
read-Option ändert den Type des Signals
viewChild<ElementRef>(‘x’, { read: ElementRef }) returnt Signal<ElementRef | undefined>. TypeScript kann den Read-Type aus dem Options-Object inferieren, oder du gibst ihn als zweites Generic an. Häufiger Fehler: Component-Klasse als Locator + read: ElementRef ohne Generic — der Type stimmt dann nicht mit dem tatsächlichen Wert überein.
contentChildren() updated bei dynamischer Projektion
Wenn der Parent mit @for verschiedene Tabs projiziert, aktualisiert sich das Array automatisch. Das alte QueryList.changes.subscribe()-Pattern fällt komplett weg — die Reactivity ist Teil des Signal-Werts. Beachte aber: descendants: false (Default) findet nur direkte Kinder; verschachtelte Wrapper machen die Liste leer.
Signal-Queries arbeiten gut mit OnPush und Zoneless
Reactivity ist Teil der Signal-Welt — kein Zone.js-Trigger nötig, keine manuelle markForCheck()-Aufrufe. Genau richtig für die OnPush-Default-Welt ab v22 und für Zoneless-Apps. Klassische Decorator-Queries spielen hier weniger sauber; du brauchst oft ChangeDetectorRef.markForCheck() nach QueryList.changes.
Generic-Typing: explizit bei Template-References
viewChild(MyComp) inferiert den Type automatisch aus der Klasse. Bei String-Locatoren kann TypeScript nicht raten — gib das Generic explizit an: viewChild<ElementRef<HTMLInputElement>>(‘email’). Ohne Generic bekommst du Signal<unknown | undefined>, was in computed() sofort zu Type-Errors führt.
Klassische Decorator-Queries dürfen daneben bestehen
Du kannst @ViewChild und viewChild() in derselben Component mischen — Angular behandelt beide Stile parallel. Praktisch bei schrittweiser Migration eines großen Trees: ein Element pro Sitzung umstellen, der Rest läuft unverändert weiter. Auch @ContentChild + contentChild() sind nebeneinander erlaubt.
Weiterführende Ressourcen
Externe Quellen
- Referencing component children with queries – Angular.dev
- viewChild API Referenz – Angular.dev
- viewChildren API Referenz – Angular.dev
- contentChild API Referenz – Angular.dev
- contentChildren API Referenz – Angular.dev
- Signal API Referenz – Angular.dev