skip to content

An Angular child component sets a page title in a shared service during ngAfterViewInit, and the parent's header binding throws NG0100; why, and what is the real fix?

level: seniorimportance: must knowfreq 55%

answer

  1. the parent rendered first
  2. the write flows upwards
  3. setTimeout only postpones it
  4. a signal lets Angular re-run

basics

~20 s

The parent's header binding was evaluated before the child's ngAfterViewInit ran, so the child changed a value already rendered. Hold the title in a signal the header reads, so Angular re-renders it in the same tick, or set it earlier; setTimeout only hides it.

solid answer

~50 s

Angular checks top-down: the parent `Dashboard` evaluates `{{ titles.current }}`, then checks its child, then runs the child's `ngAfterViewInit`, which sets `titles.current = 'Quarterly report'`. The dev-mode verification pass re-reads the header, gets a different string and throws NG0100: data flowed back up to an already-checked view. `setTimeout` or `Promise.resolve().then()` around the write only postpones it to another change detection: the header flickers, a whole extra pass runs, an `OnPush` header is not refreshed at all, and in a zoneless app a plain field changed in a timer notifies nothing. `detectChanges()` in the child re-checks the child, not the parent. The real fix is to make the title a **signal** in the service and read `titles.current()` in the header: a signal write marks the header's view and Angular re-runs it in the same tick, with no error. Better still, set the title before the parent is checked.

code

ts · 31 lines
ts
import {AfterViewInit, Component, Injectable, inject, signal} from '@angular/core';

@Injectable({providedIn: 'root'})
export class PageTitle {
  readonly current = signal('Dashboard');
}

@Component({
  selector: 'app-report-page',
  template: `<section>Report body</section>`,
})
export class ReportPage implements AfterViewInit {
  private readonly titles = inject(PageTitle);

  ngAfterViewInit() {
    // A signal write: Angular re-runs the header's view in the same tick.
    this.titles.current.set('Quarterly report');
  }
}

@Component({
  selector: 'app-dashboard',
  imports: [ReportPage],
  template: `
    <h1>{{ titles.current() }}</h1>
    <app-report-page />
  `,
})
export class Dashboard {
  protected readonly titles = inject(PageTitle);
}

go deeper

for a junior

Recognise that a child changing something its parent already displayed causes NG0100, and that setTimeout is a workaround, not a fix.

for a middle

Walk through the top-down check order that makes the write late, and explain what each common workaround actually does.

for a senior

Fix it with a signal-backed service or by restructuring the data flow, and explain why v22's OnPush default can hide the error without hiding the bug.

for a principal

Set a convention that shared UI state lives in signals and flows downward, so late writes are both rare and harmless across teams.

## The setup A `Dashboard` component shows a header whose text comes from a root service, `PageTitle`. It also renders a child, `ReportPage`, which decides the title once its own view is ready: ```ts @Injectable({providedIn: 'root'}) export class PageTitle { current = 'Dashboard'; } // Dashboard template: <h1>{{ titles.current }}</h1> <app-report-page /> export class ReportPage implements AfterViewInit { private readonly titles = inject(PageTitle); ngAfterViewInit() { this.titles.current = 'Quarterly report'; } } ``` With an `Eager` dashboard (the behaviour of most pre-v22 code), development mode throws **NG0100** with `Previous value: 'Dashboard'. Current value: 'Quarterly report'`. ## Why it throws Angular checks views **top-down** in one pass: 1. It refreshes `Dashboard`, evaluating `{{ titles.current }}` as `'Dashboard'` and writing it to the `<h1>`. 2. It moves on to the child `ReportPage` and checks its view. 3. After the child's view is checked, Angular runs its `ngAfterViewInit`, which changes the service field. 4. The development **verification pass** re-evaluates the dashboard's binding, now gets `'Quarterly report'`, and throws. The child wrote *upwards* into state that an ancestor had already rendered. The components guide calls this out directly: never write state into parent or ancestor components, because it leads to brittle code and NG0100. ## The fixes that are not fixes | Workaround | What actually happens | |---|---| | `setTimeout(() => ...)` or `Promise.resolve().then(...)` | Zone-based apps run a second full change detection; the header flickers from the old to the new title | | Same, in a zoneless app | A plain field set in a timer notifies nothing; the header stays stale | | Same, with an `OnPush` header | The second pass skips the header because it was never marked dirty | | `ChangeDetectorRef.detectChanges()` in the child | Re-checks the child's view only; the parent's binding still differs | | Production build | The error disappears because the check is gone; the stale header remains | All of them treat the symptom. The ordering problem, a child writing back up the tree after the parent was checked, is still there. ## The real fixes **1. Make the shared state a signal.** Change the service to `readonly current = signal('Dashboard')`, read `titles.current()` in the header, and call `set()` in the child. A signal read by a template makes that template a reactive consumer; a write marks the header's view for refresh and its ancestors for traversal, and Angular re-runs those views in the same tick before the verification pass. The header shows the new title immediately, with no error and no second full change detection. Angular's own test suite covers this behaviour: signals set in `ngAfterViewInit` or `ngAfterViewChecked` render without NG0100, including when the signal is read by a view that was checked earlier. **2. Remove the backwards flow.** Better still, the title should be known before the header is checked: - let the parent own the value and pass it down, rather than the child pushing it up; - derive it from data available earlier, such as the route configuration or a resolved input; - set it where the child is created rather than after its view is checked, if the child truly owns it. Signals make the late write *safe*; removing the late write makes the flow *simple*. ## How to confirm the diagnosis 1. Read the NG0100 message: the expression location names `Dashboard`, and the previous and current values are the two titles. 2. Search for every writer of `PageTitle.current`; the one in `ReportPage.ngAfterViewInit` is the late one. 3. Move the write into the constructor temporarily; if the error disappears, the ordering diagnosis is confirmed. 4. Apply the lasting fix (a signal, or a downward data flow) rather than keeping the temporary move if the child only knows the title later. The same shape appears with breadcrumbs, toolbar actions, unsaved-changes badges and any other layout element that a routed or nested page wants to configure: the layout renders first, the page speaks up afterwards. ## A v22 twist Since v22, components are `OnPush` by default. The default verification pass skips `OnPush` views that are not dirty, so the same code in a v22 `Dashboard` usually does **not** throw: it silently shows `'Dashboard'` until something else marks the header dirty. The bug did not go away; it lost its alarm. `provideCheckNoChangesConfig({exhaustive: true})` in development brings the alarm back, and the signal fix removes the bug in either strategy.

  • Why does a signal write in an Angular child's ngAfterViewInit not throw NG0100 while a plain field write does?
    A template that reads a signal is a reactive consumer. Writing the signal marks that view dirty and its ancestors for traversal, and Angular keeps re-running the marked views within the same tick until none are dirty, before the verification pass runs. A plain field has no such link, so the verification pass is the first thing to notice the change, and it throws.
  • Can signal writes during Angular change detection loop forever?
    Angular caps the re-runs. If views keep marking each other dirty, for example two components each updating a signal the other reads, it stops after a fixed number of refresh rounds and throws NG0103, infinite change detection. Signals make an occasional backwards write safe; they do not make circular data flow correct.

saying these in an interview costs you the question

  • Wrapping the write in setTimeout is the correct fix.
  • Calling detectChanges() in the child fixes the parent's binding.
  • The error is spurious because the final value is right.
  • Switching the header to OnPush fixes the bug.
  • Signals just suppress the dev-mode check.