skip to content

In Angular, how does a @ViewChildren QueryList report result changes, and what replaces that with the viewChildren() signal query?

level: middleimportance: nice to knowfreq 28%

answer

  1. changes is an Observable
  2. emits after, not the current set
  3. distinct changes only
  4. a readonly array in a signal

basics

~20 s

@ViewChildren and @ContentChildren fill a QueryList whose changes Observable emits when the matches change, not the initial set. viewChildren() and contentChildren() instead return a signal of a readonly array that notifies computed() and effect() dependants.

solid answer

~40 s

The plural decorators assign a `QueryList<T>`: an iterable, array-like object with `length`, `first`, `last`, `get()`, `map()`, `filter()`, `find()`, `forEach()`, `reduce()`, `some()` and `toArray()`. To react to additions and removals you subscribe to `changes`, an `Observable` that emits the `QueryList` itself when results change; it does not replay the current set, so code often starts with the current value or pipes `startWith`. By default it emits only when the results actually changed (the `emitDistinctChangesOnly` option, which defaults to `true` and is deprecated). The signal functions `viewChildren()` and `contentChildren()` return a `Signal<ReadonlyArray<T>>`: read it in the template, `computed()` or `effect()`, and dependants update when the matches change. No subscription or teardown is needed.

go deeper

for a junior

Remember that the plural decorators give a QueryList and the signal functions give a signal of an array.

for a middle

Explain how changes behaves, including no replay of the initial set, distinct-only emission and the teardown you owe.

for a senior

Replace changes subscriptions with computed or effect over signal queries during migrations, and spot leaked or late subscriptions in older code.

for a principal

Plan the migration of a large codebase's decorator queries with the schematic, and decide where Observable-based consumers still justify QueryList.

## Two shapes for multi-result queries Angular offers two ways to collect **all** matching children: - The decorators `@ViewChildren` and `@ContentChildren`, which assign a **`QueryList<T>`** to a class property. - The signal functions `viewChildren()` and `contentChildren()`, which return a **`Signal<ReadonlyArray<T>>`**. Both follow the same matching rules (locator, `read`, and for content queries `descendants`). They differ in how you consume results and learn about changes. ## What a QueryList offers `QueryList` is array-like and iterable, so it works in `for...of` and in template loops. Its members include: - `length`, `first`, `last` and `get(index)` - `map()`, `filter()`, `find()`, `reduce()`, `forEach()` and `some()` - `toArray()` for a plain array copy - **`changes`**, an `Observable` that emits the `QueryList` when results change The list is populated before `ngAfterViewInit` (view queries) or `ngAfterContentInit` (content queries), and Angular updates it in place as `@if` and `@for` add or remove matches. ## How changes behaves 1. It is backed by a subject, so a subscriber sees only **later** changes; there is no replay of the results that exist when you subscribe. 2. It emits only when the set of results actually changed. That is governed by `emitDistinctChangesOnly`, which defaults to `true`; the option is marked deprecated and will be fixed at `true`. 3. The subscription is yours to end. A long-lived component that subscribes must tear it down when destroyed. A common decorator pattern therefore handles the initial set and later changes separately: ```ts import {AfterViewInit, Component, DestroyRef, QueryList, ViewChildren, inject} from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {startWith} from 'rxjs'; import {Step} from './step'; @Component({selector: 'app-wizard', imports: [Step], template: `...`}) export class Wizard implements AfterViewInit { @ViewChildren(Step) steps!: QueryList<Step>; private destroyRef = inject(DestroyRef); ngAfterViewInit() { this.steps.changes .pipe(startWith(this.steps), takeUntilDestroyed(this.destroyRef)) .subscribe((list: QueryList<Step>) => console.log('steps', list.length)); } } ``` ## The signal version ```ts import {Component, computed, viewChildren} from '@angular/core'; import {Step} from './step'; @Component({selector: 'app-wizard', imports: [Step], template: `...`}) export class Wizard { steps = viewChildren(Step); count = computed(() => this.steps().length); } ``` The signal starts as an empty array, becomes the current matches once they are collected, and notifies dependants when they change. The array is read-only, and in Angular 22.2 the same array reference is returned while a check leaves the results unchanged, so a `computed()` over it does not rerun for nothing. ## Side by side | Aspect | `QueryList` (decorators) | `Signal<ReadonlyArray>` (functions) | |---|---|---| | Initial value | populated before the `After...Init` hook | empty array, then current matches | | Reacting to change | subscribe to `changes` | read in `computed()`, `effect()` or template | | Initial set in the stream | not replayed | always the current value when read | | Teardown | you unsubscribe | none | | Distinct-only emission | `emitDistinctChangesOnly`, default `true` | notifies only when results changed | | Container | array-like `QueryList` | plain read-only array | ## When each still matters - **Existing code** is full of `QueryList` and `changes` subscriptions, so interviewers still ask how they work. - **New code** in Angular 22.2 should prefer the signal functions: they compose with `computed()`, need no teardown, and have no separate initial-versus-later handling. - The `signal-queries-migration` schematic (`ng generate @angular/core:signal-queries-migration`) converts decorator queries and their references. ## Consuming the results in a template Both forms can drive markup. A `QueryList` is iterable, so `@for (s of steps; track s)` works over the decorator property once it is set. The signal form is called: `@for (s of steps(); track s)`. The difference shows up in derived values. With a `QueryList`, a count or a filtered subset shown in the template has to be recomputed by hand in a `changes` handler and stored in a field. With a signal query, a `computed()` such as `visibleSteps = computed(() => this.steps().filter(s => s.visible()))` stays correct by construction, because it reruns when either the matches or the signals it reads change. ## Pitfalls - Subscribing to `changes` in the constructor: the property is not assigned yet. - Forgetting that `changes` skips the initial set and wondering why nothing logs until the list changes. - Mutating the array returned by a signal query: it is read-only by type, and the array belongs to the query.

  • Why do many QueryList subscriptions pipe startWith before subscribing?
    `changes` does not replay the current results; it only emits on later changes. `startWith(this.list)` makes the stream begin with the set that already exists, so one handler covers both the initial and the changed results.
  • What does emitDistinctChangesOnly control?
    Whether `QueryList.changes` emits only when the results really changed. It defaults to `true`; setting it to `false` lets the stream emit even when nothing changed. The option is deprecated and will be fixed at `true`.

saying these in an interview costs you the question

  • QueryList.changes emits the current results as soon as you subscribe
  • viewChildren() returns a QueryList you subscribe to
  • Signal query arrays should be mutated to reorder children
  • A changes subscription needs no teardown in a long-lived component
  • emitDistinctChangesOnly defaults to false