skip to content

After upgrading an Angular app to v22, a new countdown component that decrements a plain field in setInterval never updates on screen; which two version changes do you check?

level: seniorimportance: should knowfreq 48%

answer

  1. is there a tick at all
  2. is the view checked in that tick
  3. v21 default, v22 default
  4. a fix that works in both modes

basics

~20 s

Zoneless became the default in v21, so without provideZoneChangeDetection() a timer triggers no tick; and since v22 components are OnPush by default, so even a zone tick skips a new component whose plain field changed. A signal fixes both.

solid answer

~40 s

Split the problem into "did a tick run" and "was this view checked". Since v21, applications are zoneless unless they call `provideZoneChangeDetection()`; the v21 update migration adds that provider to existing apps, but if it is missing, a `setTimeout` notifies nothing and no tick runs. Since v22, a component with no `changeDetection` value is `OnPush`; the v22 migration adds `ChangeDetectionStrategy.Eager` to existing components, but a **new** component gets `OnPush`. Under `OnPush`, a zone-triggered `ApplicationRef.tick()` still skips the view, because assigning a plain field in a timer does not mark it dirty. The durable fix is to hold the value in a `signal()` and call `set()` or `update()` in the timer: the write marks the view that reads it and notifies the scheduler, so it works with or without zone.js.

code

ts · 17 lines
ts
import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-countdown',
  template: `<p>{{ remaining() }} s left</p>`,
})
export class Countdown {
  protected readonly remaining = signal(10);

  constructor() {
    const id = setInterval(() => {
      this.remaining.update(n => n - 1);
      if (this.remaining() === 0) clearInterval(id);
    }, 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(id));
  }
}

go deeper

for a junior

Remember the two defaults: zoneless since v21 and OnPush since v22, and that a signal updates the view in either setup.

for a middle

Separate whether a tick runs from whether the view is checked, and map each to provideZoneChangeDetection() and ChangeDetectionStrategy.

for a senior

Diagnose which of the two changes bit with quick experiments, and choose a signal-based fix over restoring old defaults.

for a principal

Turn the incident into an upgrade checklist: audit timer- and callback-driven views before the upgrade and set a policy for new components.

## The symptom ```ts import { Component } from '@angular/core'; @Component({ selector: 'app-countdown', template: `<p>{{ remaining }} s left</p>`, }) export class Countdown { remaining = 10; constructor() { const id = setInterval(() => { this.remaining--; if (this.remaining === 0) clearInterval(id); }, 1000); } } ``` The field decrements (a `console.log` confirms it), yet the screen stays at `10`. The same component written two majors ago worked. ## Two questions, two version changes Refreshing a view needs **two** things: a change-detection pass must run, and that pass must visit this view. Angular 21 and 22 each changed one of them. | Question | What changed | Version | |---|---|---| | Does a tick run after the timer? | zoneless is the default; the zone scheduler exists only with `provideZoneChangeDetection()` | v21 | | Does the tick check this view? | components without `changeDetection` are `OnPush` | v22 | ### Check 1: is zone-based scheduling on? In a zone-based app, the patched `setInterval` runs its callback in the Angular zone, `NgZone` emits `onMicrotaskEmpty`, and Angular calls `ApplicationRef.tick()`. In a **zoneless** app nothing observes the timer. Look at the bootstrap providers: - `provideZoneChangeDetection()` present and `zone.js` in the build's polyfills: timers trigger ticks. - Neither present: the app is zoneless and a plain field assignment in a timer is invisible. The v21 breaking change says Angular no longer provides the zone scheduler by default, with an automated migration that adds the provider. A project that skipped the migration, or removed the provider, loses timer-driven ticks. ### Check 2: is the component OnPush? Even when a tick runs, it refreshes `Eager` views (checked every pass) and `OnPush` views that are marked dirty. Since v22 a component with no `changeDetection` value is `OnPush`. The v22 migration adds `ChangeDetectionStrategy.Eager` to existing components where needed, so old components keep working, but a component written after the upgrade is `OnPush`. A field assignment in a timer does not mark an `OnPush` view dirty, so the tick walks past it. ## Fixes, from best to worst 1. **Use a signal.** `remaining = signal(10)` and `this.remaining.update(n => n - 1)`. The template reads `remaining()`; the write marks that view and notifies the scheduler. Works zoneless or zone-based, `OnPush` or `Eager`. 2. **Mark the view.** Inject `ChangeDetectorRef` and call `markForCheck()` after the assignment. It marks the view and its ancestors and also schedules a tick in zoneless apps, but every future edit must remember it. 3. **Opt back into the old defaults.** Add `provideZoneChangeDetection()` and `changeDetection: ChangeDetectionStrategy.Eager`. This reproduces the pre-v21 behaviour at the cost of whole-tree checks after every async event. Only the first survives future changes without anyone remembering a rule. ## How to confirm which check failed - Log inside the timer and in a template-bound getter: if the getter is never called after the timer, no pass visited the view. - Log the static `NgZone.isInAngularZone()` in the callback; in a zoneless app it is always `false`. - Temporarily set `changeDetection: ChangeDetectionStrategy.Eager`: if the view now updates, a tick was running and `OnPush` was the cause. ## Why the migrations did not catch it Both upgrades ship automated migrations, and both protect **existing** code: - the v21 migration adds `provideZoneChangeDetection()` to apps that relied on zone scheduling, - the v22 migration adds `ChangeDetectionStrategy.Eager` to existing components where needed. Neither can protect a component written **after** the upgrade by a developer who still has the old defaults in mind. The countdown here is new code, so it inherits both new defaults. The same applies to components copied from older tutorials or internal snippets, which silently assumed zone.js and eager checking. A team-level fix is to write new timer- or callback-driven state as signals from the start, and to review for plain-field assignments inside `setTimeout`, `setInterval`, `subscribe` callbacks and third-party callbacks. ## Takeaway "It worked before the upgrade" on a timer-driven view usually means the view relied on zone.js inference and `Eager` checking. Those were the defaults for years; since v21 and v22 they are opt-in.

  • Why does markForCheck() also fix the zoneless case, not only the OnPush one?
    `markForCheck()` marks the view and its ancestors dirty and sends a notification to Angular's change-detection scheduler, which schedules a tick when no zone will do it. So it restores both halves: a tick runs, and the view is dirty when it does. It still relies on every author remembering the call.
  • Is adding provideZoneChangeDetection() back a reasonable fix for a large legacy app?
    As a stopgap, yes: it restores tick-after-every-async-event behaviour that the code was written against. It keeps zone.js's payload and whole-tree checks, so it should come with a plan to move state to signals or explicit marking, component by component.

saying these in an interview costs you the question

  • Since v22 Angular ignores setTimeout callbacks entirely, even with zone.js
  • OnPush components are refreshed by every zone-triggered tick
  • Adding Eager alone restores updates in a zoneless app
  • The v22 migration makes every new component Eager automatically
  • A signal only helps in zoneless apps, not zone-based ones