Back to CourseIntermediate4 min read
Angular /

ViewChild and ContentChild Decorators

Understand the query decorators in older Angular code, the static flag, QueryList, and how they map onto signal queries.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: June 9, 2026
Last updated: September 7, 2026
Intermediate Topic

The decorator queries

Before signal queries, you found elements and children with decorators.

import { Component, ViewChild, ViewChildren, ContentChild, ContentChildren,
         ElementRef, QueryList, AfterViewInit } from '@angular/core';

@Component({
  selector: 'app-search',
  template: `<input #box>`,
})
export class SearchComponent implements AfterViewInit {
  @ViewChild('box') box!: ElementRef<HTMLInputElement>;

  ngAfterViewInit(): void {
    this.box.nativeElement.focus();
  }
}

Note the !. The property is undefined until Angular fills it in, so TypeScript needs telling to allow it. That non-null assertion on every query is one of the things signal queries removed.

The four decorators

@ViewChild(UserCard) card!: UserCard;
@ViewChildren(UserCard) cards!: QueryList<UserCard>;
@ContentChild(TabPanel) panel!: TabPanel;
@ContentChildren(TabPanel) panels!: QueryList<TabPanel>;

Same view versus content distinction as the signal versions. View is your own template, content is what the caller projected in.

The static flag

This one caused real confusion.

@ViewChild('box', { static: true }) box!: ElementRef;
@ViewChild('box', { static: false }) box!: ElementRef;

static: true meant the element is always in the DOM, so the result is available in ngOnInit. static: false meant it might be inside an *ngIf, so it is only available in ngAfterViewInit.

Get it wrong and you got undefined at a time you did not expect.

// static: true, available early
@ViewChild('always', { static: true }) always!: ElementRef;

ngOnInit(): void {
  this.always.nativeElement.focus();     // works
}

// static: false, not available yet
@ViewChild('maybe', { static: false }) maybe!: ElementRef;

ngOnInit(): void {
  this.maybe.nativeElement.focus();      // throws, undefined
}

ngAfterViewInit(): void {
  this.maybe?.nativeElement.focus();     // works
}

Signal queries removed the flag entirely, because a signal simply has no value until it has one.

QueryList

The plural decorators return a QueryList, which is a custom collection rather than an array.

@ViewChildren(Row) rows!: QueryList<Row>;

ngAfterViewInit(): void {
  console.log(this.rows.length);
  console.log(this.rows.first);
  console.log(this.rows.last);

  this.rows.forEach(row => row.reset());

  const array = this.rows.toArray();

  // react to changes
  this.rows.changes.subscribe((list: QueryList<Row>) => {
    console.log('now', list.length);
  });
}

changes is an observable, so you must subscribe and handle the initial value separately, since it does not emit on the first render.

ngAfterViewInit(): void {
  this.recount();                                    // initial
  this.rows.changes.subscribe(() => this.recount()); // subsequent
}

Forgetting the initial call is a common bug. viewChildren() has no such problem, since a signal always has a current value.

The read option

Same as the signal version. It picks which thing you get from a matched element.

@ViewChild(UserCard) instance!: UserCard;
@ViewChild(UserCard, { read: ElementRef }) element!: ElementRef;
@ViewChild('slot', { read: ViewContainerRef }) container!: ViewContainerRef;
@ViewChild('tpl', { read: TemplateRef }) template!: TemplateRef<unknown>;

descendants

@ContentChildren(MenuItem) direct!: QueryList<MenuItem>;
@ContentChildren(MenuItem, { descendants: true }) all!: QueryList<MenuItem>;

Default is direct children only, same as the signal version.

A full example

@Component({
  selector: 'app-tabs',
  template: `
    <nav>
      <button *ngFor="let tab of tabList" (click)="select(tab)">
        {{ tab.label }}
      </button>
    </nav>
    <ng-content />
  `,
})
export class TabsComponent implements AfterContentInit, OnDestroy {
  @ContentChildren(TabComponent) tabs!: QueryList<TabComponent>;

  tabList: TabComponent[] = [];
  private subscription?: Subscription;

  ngAfterContentInit(): void {
    this.rebuild();
    this.subscription = this.tabs.changes.subscribe(() => this.rebuild());
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }

  private rebuild(): void {
    this.tabList = this.tabs.toArray();
    if (this.tabList.length > 0) this.select(this.tabList[0]);
  }

  select(tab: TabComponent): void { ... }
}

The signal version of the same thing:

@Component({
  selector: 'app-tabs',
  template: `
    <nav>
      @for (tab of tabs(); track tab.label()) {
        <button (click)="select(tab)">{{ tab.label() }}</button>
      }
    </nav>
    <ng-content />
  `,
})
export class Tabs {
  readonly tabs = contentChildren(Tab);

  protected select(tab: Tab): void { ... }
}

Two hooks, a subscription, an unsubscribe and a mirrored array all disappear. The template reads the query directly.

The mapping

// decorators                                     signals
@ViewChild('box') box!: ElementRef;               readonly box = viewChild<ElementRef>('box');
@ViewChild('box', { static: true })               readonly box = viewChild.required<ElementRef>('box');
@ViewChildren(Row) rows!: QueryList<Row>;         readonly rows = viewChildren(Row);
@ContentChild(TemplateRef) tpl!: TemplateRef;     readonly tpl = contentChild(TemplateRef);
@ContentChildren(Tab, { descendants: true })      readonly tabs = contentChildren(Tab, { descendants: true });

this.rows.changes.subscribe(...)                  computed(() => this.rows().length)
ngAfterViewInit() { ... }                         effect(() => { ... })

Migrate with:

ng generate @angular/core:signal-queries-migration

Things to check when migrating

QueryList methods. first, last, toArray, forEach and changes have no direct equivalent, since you get a plain array.

this.rows.first          ->  this.rows()[0]
this.rows.last           ->  this.rows().at(-1)
this.rows.toArray()      ->  this.rows()
this.rows.forEach(fn)    ->  this.rows().forEach(fn)
this.rows.changes        ->  computed() or effect()

The static flag. Both cases become the same signal query. Where it was static: true and read in ngOnInit, move the read into an effect.

Non-null assertions. Every ! on a query property should either disappear with .required or become a genuine null check.

Quick recap

  • @ViewChild and friends still work and appear throughout existing code.
  • They need ! because Angular fills them in after construction.
  • The static flag decided whether a result was ready in ngOnInit, and was easy to get wrong.
  • QueryList needed a changes subscription plus a separate initial call.
  • Signal queries removed the flag, the QueryList and the lifecycle hooks.
  • Map first, last and toArray onto plain array operations.
  • Migrate with ng generate @angular/core:signal-queries-migration.
app.ts

import { Component, ViewChild, ViewChildren, ContentChild, ContentChildren,
         ElementRef, QueryList, TemplateRef, ViewContainerRef,
         AfterViewInit, AfterContentInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
@Component({ selector: 'app-legacy-queries', template: `<input #box><ng-content />` })
export class LegacyQueries implements AfterViewInit, AfterContentInit, OnDestroy {
  // The ! is needed because Angular fills these in AFTER construction
  @ViewChild('box') box!: ElementRef<HTMLInputElement>;
  // static: true  = always in the DOM, available in ngOnInit
  // static: false = might be inside *ngIf, only in ngAfterViewInit
  @ViewChild('always', { static: true }) always!: ElementRef;
  // read picks WHICH thing you get from the matched element
  @ViewChild('slot', { read: ViewContainerRef }) slot!: ViewContainerRef;
  @ContentChild(TemplateRef) tpl!: TemplateRef<unknown>;
  @ViewChildren(Row) rows!: QueryList<Row>;
  @ContentChildren(Tab, { descendants: true }) tabs!: QueryList<Tab>;
  private subscription?: Subscription;
  ngAfterViewInit(): void {
    this.box.nativeElement.focus();
    console.log(this.rows.length, this.rows.first, this.rows.last);
  }
  ngAfterContentInit(): void {
    this.rebuild();                                    // changes does NOT emit
    this.subscription = this.tabs.changes.subscribe(() => this.rebuild());
  }                                                    // initially. Forgetting
  ngOnDestroy(): void { this.subscription?.unsubscribe(); }   // the first call is
  private rebuild(): void {}                                  // a classic bug.
}
// The signal versions remove the !, the flag, the QueryList and the hooks:
//   @ViewChild('box') box!: ElementRef        -> viewChild.required<ElementRef>('box')
//   @ViewChildren(Row) rows!: QueryList<Row>  -> viewChildren(Row)
//   @ContentChildren(Tab, {descendants:true}) -> contentChildren(Tab, {descendants:true})
//   this.rows.changes.subscribe(...)          -> computed(() => this.rows().length)
//   ngAfterViewInit() { ... }                 -> effect(() => { ... })
// QueryList methods map onto plain arrays:
//   rows.first     ->  rows()[0]
//   rows.last      ->  rows().at(-1)
//   rows.toArray() ->  rows()
// Migrate with:  ng generate @angular/core:signal-queries-migration
class Row {}
class Tab {}

Was this explanation helpful?

Your feedback helps us improve the content for everyone.