📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~6 min de lecture

markForCheck() en 2026 : les 4 cas où tu peux enfin le virer

Tu as migré ta codebase vers les signals. signal(), computed(), input(), output() partout. Mais tu croises encore régulièrement ce vieux réflexe :

this.cdr.markForCheck();

Un ChangeDetectorRef injecté, un markForCheck() collé après chaque callback asynchrone, un composant OnPush qui refuse de rerender sans ce coup de pouce. Trois signes que tu es resté bloqué en 2020 alors que le reste du framework a bougé.

La bonne nouvelle : dans 90% des cas, ce markForCheck() peut disparaître. Pas parce que la méthode est deprecated (elle ne l'est pas), mais parce que les APIs qui la rendaient nécessaire ont été remplacées par des équivalents signal qui déclenchent la détection de changement automatiquement.

Ce guide couvre les 4 patterns où tu peux le supprimer sans risque, et le seul cas où il reste indispensable. Angular v17+ minimum, avec quelques nuances pour zoneless (v20+).


Rappel : à quoi sert vraiment markForCheck()

OnPush dit à Angular : "ne re-check ce composant que dans ces 4 cas seulement" :

  1. Un input reçoit une nouvelle référence
  2. Un event bindé dans le template ((click), (keydown)...) déclenche
  3. Un AsyncPipe émet
  4. Quelqu'un appelle explicitement markForCheck() sur son ChangeDetectorRef

Avant les signals, tout code qui touchait une propriété du composant en dehors de ces 4 canaux devait rappeler markForCheck() manuellement. Sinon la vue restait stale.

Depuis les signals, un 5e canal est venu s'ajouter : la lecture d'un signal dans le template marque automatiquement le composant pour un check dès que ce signal change. C'est ce nouveau canal qui rend markForCheck() inutile dans la plupart des cas.


Cas 1 - Le service BehaviorSubject qui vieillit

Le classique : un service qui expose un BehaviorSubject, le composant s'abonne, et forcément il faut markForCheck() dans le next parce que subscribe ne passe pas par les 4 canaux natifs OnPush.

Avant

import {
  ChangeDetectorRef,
  ChangeDetectionStrategy,
  Component,
  inject,
  OnDestroy,
  OnInit,
} from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { UserService, User } from './user.service';

@Component({
  selector: 'app-profile',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (user) {
      <h1>{{ user.name }}</h1>
    }
  `,
})
export class Profile implements OnInit, OnDestroy {
  private readonly userService = inject(UserService);
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly destroy$ = new Subject<void>();

  user: User | null = null;

  ngOnInit(): void {
    this.userService.user$
      .pipe(takeUntil(this.destroy$))
      .subscribe((user) => {
        this.user = user;
        this.cdr.markForCheck();
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Six lignes de plumbing pour lire un champ. Le markForCheck est là parce que sans lui la vue reste vide.

Après

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app-profile',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (user(); as u) {
      <h1>{{ u.name }}</h1>
    }
  `,
})
export class Profile {
  private readonly userService = inject(UserService);
  readonly user = this.userService.user;
}

Le service expose maintenant user comme signal, typiquement via toSignal(this.user$) ou directement signal<User | null>(null). Le composant lit ce signal dans le template, Angular s'occupe de tout : plus de subscribe, plus de destroy$, plus de ChangeDetectorRef.

Règle : si tu injectes ChangeDetectorRef uniquement pour cadrer un subscribe, migre la source en signal. markForCheck disparaît en même temps que takeUntil.


Cas 2 - Le HTTP fetch avec loading/error tracking manuel

Autre grand classique : un composant qui fait un HttpClient.get(), gère loading, error, data, et sème des markForCheck() dans chaque callback.

Avant

import {
  ChangeDetectorRef,
  ChangeDetectionStrategy,
  Component,
  inject,
  OnInit,
} from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface Article {
  id: string;
  title: string;
}

@Component({
  selector: 'app-articles',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (loading) {
      <p>Chargement...</p>
    }
    @if (error) {
      <p>Erreur : {{ error }}</p>
    }
    @for (article of articles; track article.id) {
      <article>{{ article.title }}</article>
    }
  `,
})
export class Articles implements OnInit {
  private readonly http = inject(HttpClient);
  private readonly cdr = inject(ChangeDetectorRef);

  loading = true;
  error: string | null = null;
  articles: Article[] = [];

  ngOnInit(): void {
    this.http.get<Article[]>('/api/articles').subscribe({
      next: (data) => {
        this.articles = data;
        this.loading = false;
        this.cdr.markForCheck();
      },
      error: (err) => {
        this.error = err.message;
        this.loading = false;
        this.cdr.markForCheck();
      },
    });
  }
}

Trois markForCheck implicites (à travers loading et error). Aucun ne garantit anti-race, aucun ne gère l'annulation si le composant est détruit avant la fin du fetch.

Après

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { httpResource } from '@angular/common/http';

interface Article {
  id: string;
  title: string;
}

@Component({
  selector: 'app-articles',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (articles.error()) {
      <p>Erreur : {{ articles.error() }}</p>
    } @else if (articles.isLoading()) {
      <p>Chargement...</p>
    } @else if (articles.hasValue()) {
      @for (article of articles.value(); track article.id) {
        <article>{{ article.title }}</article>
      }
    }
  `,
})
export class Articles {
  readonly articles = httpResource<Article[]>(() => '/api/articles');
}

httpResource expose isLoading, error, value sous forme de signals. La détection de changement est câblée par le framework. Aucun ChangeDetectorRef, aucune subscription à trainer.

Attention au piège classique : lire value() alors que la resource est en état d'erreur lève une exception à l'exécution. Passe toujours par hasValue() avant de lire value() (comme ci-dessus), c'est à la fois un garde-fou runtime et un type guard qui retire undefined du type.

Si tu préfères garder ton service (recommandé côté architecture, cf notre article sur httpResource comme anti-pattern), remplace juste subscribe par resource(() => firstValueFrom(this.service.getArticles())) ou rxResource({ stream: () => this.service.getArticles$() }). L'effet côté composant est le même : plus de markForCheck.


Cas 3 - Le timer et l'interval du dashboard temps-réel

Tu affiches un timestamp qui se met à jour toutes les secondes, ou un compte à rebours. Avant, setInterval + markForCheck obligatoires.

Avant

import {
  ChangeDetectorRef,
  ChangeDetectionStrategy,
  Component,
  inject,
  OnDestroy,
  OnInit,
} from '@angular/core';

@Component({
  selector: 'app-clock',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ now.toLocaleTimeString() }}</span>`,
})
export class Clock implements OnInit, OnDestroy {
  private readonly cdr = inject(ChangeDetectorRef);

  now = new Date();
  private handle: ReturnType<typeof setInterval> | null = null;

  ngOnInit(): void {
    this.handle = setInterval(() => {
      this.now = new Date();
      this.cdr.markForCheck();
    }, 1000);
  }

  ngOnDestroy(): void {
    if (this.handle) {
      clearInterval(this.handle);
    }
  }
}

setInterval s'exécute hors de tout canal OnPush. Sans markForCheck, l'écran est figé sur l'heure de mount.

Après

import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  inject,
  signal,
} from '@angular/core';

@Component({
  selector: 'app-clock',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ now().toLocaleTimeString() }}</span>`,
})
export class Clock {
  readonly now = signal(new Date());

  constructor() {
    const handle = setInterval(() => this.now.set(new Date()), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(handle));
  }
}

Le setInterval reste (c'est une API browser, pas Angular), mais il écrit dans un signal. Angular sait suivre. Zéro markForCheck, zéro ChangeDetectorRef, et bonus : le cleanup est colocalisé avec le setup grâce à DestroyRef.

En zoneless (v20+), markForCheck reste un déclencheur explicite reconnu par le scheduler zoneless : le pattern "Avant" continuerait de fonctionner. Le vrai gain du signal, c'est qu'il n'y a plus aucun appel manuel à retenir : la propagation vers le scheduler est automatique dès que le signal change, sans dépendre d'un ChangeDetectorRef injecté.


Cas 4 - Le callback tiers (IntersectionObserver, WebSocket, drag-and-drop navigateur)

Une lib externe ou une API browser t'appelle un handler dès qu'un event arrive. Ces callbacks tournent hors des canaux OnPush.

Avant

import {
  AfterViewInit,
  ChangeDetectorRef,
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  inject,
  OnDestroy,
  ViewChild,
} from '@angular/core';

@Component({
  selector: 'app-lazy-thumb',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div #anchor>
      @if (visible) {
        <img [src]="url" />
      }
    </div>
  `,
})
export class LazyThumb implements AfterViewInit, OnDestroy {
  @ViewChild('anchor', { static: true }) anchor!: ElementRef<HTMLElement>;

  private readonly cdr = inject(ChangeDetectorRef);
  private observer: IntersectionObserver | null = null;

  visible = false;
  url = '';

  ngAfterViewInit(): void {
    this.observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this.visible = true;
        this.cdr.markForCheck();
        this.observer?.disconnect();
      }
    });
    this.observer.observe(this.anchor.nativeElement);
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
  }
}

Après

import {
  AfterViewInit,
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  ElementRef,
  inject,
  signal,
  viewChild,
} from '@angular/core';

@Component({
  selector: 'app-lazy-thumb',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div #anchor>
      @if (visible()) {
        <img [src]="url()" />
      }
    </div>
  `,
})
export class LazyThumb implements AfterViewInit {
  readonly anchor = viewChild.required<ElementRef<HTMLElement>>('anchor');
  readonly visible = signal(false);
  readonly url = signal('');

  private readonly destroyRef = inject(DestroyRef);

  ngAfterViewInit(): void {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this.visible.set(true);
        observer.disconnect();
      }
    });
    observer.observe(this.anchor().nativeElement);
    this.destroyRef.onDestroy(() => observer.disconnect());
  }
}

Le pattern est le même que pour le timer : le callback tiers écrit dans un signal, Angular voit la mutation, la vue se met à jour. markForCheck inutile.


LE cas où markForCheck reste indispensable

Après tout ça, il te reste quand même un scénario où markForCheck n'a pas de remplacement propre : quand du state non-signal est modifié depuis un callback qui échappe à la zone Angular ET que tu ne peux pas migrer ce state en signal maintenant.

Concrètement :

  • Tu wrappes une librairie legacy (un plugin jQuery, une chart lib qui expose ses callbacks en callback natif) qui appelle ton handler hors zone.
  • Le state qu'elle mute est directement bindé au template sans passer par un signal, parce que tu ne peux pas encore changer sa forme, ou qu'un décorateur @Input() classique est là le temps de la migration.

Dans ce cas précis, markForCheck reste le canal explicite pour dire à Angular "cette vue est stale, re-vérifie-la". Rien d'autre ne le remplace.

import {
  ChangeDetectorRef,
  ChangeDetectionStrategy,
  Component,
  Input,
  NgZone,
  OnInit,
  inject,
} from '@angular/core';
import { LegacyEditor } from 'some-legacy-lib';

@Component({
  selector: 'app-editor',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<pre>{{ content }}</pre>`,
})
export class Editor implements OnInit {
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly zone = inject(NgZone);

  @Input() content = '';

  ngOnInit(): void {
    LegacyEditor.attach({
      onChange: (value: string) => {
        this.zone.run(() => {
          this.content = value;
          this.cdr.markForCheck();
        });
      },
    });
  }
}

Note bien : le vrai fix, à terme, c'est de migrer content en signal input (input<string>()) et d'écrire this.content.set(value). Le NgZone.run() disparaît, le markForCheck avec.

Règle finale : si tu vois markForCheck() dans du code que tu écris en 2026, considère-le comme de la dette technique documentée. Il ne bloque pas la prod, mais il te dit qu'il reste un morceau de state hors du système de signals.


Récap actionnable

Situation Avant Après markForCheck
Service BehaviorSubject subscribe + cdr.markForCheck Service expose un signal Virer
HTTP fetch avec loading state subscribe + cdr.markForCheck httpResource, resource, rxResource ou toSignal sur le service Virer
Timer / interval setInterval + cdr.markForCheck setInterval qui écrit dans un signal Virer
Callback tiers (Observer, WebSocket) Callback + cdr.markForCheck Callback qui écrit dans un signal Virer
Legacy state hors zone Angular NgZone.run + cdr.markForCheck Migrer le state en signal input, sinon garder Garder temporairement

Trois gestes à effectuer sur ta codebase actuelle :

  1. git grep 'markForCheck' : recense chaque occurrence.
  2. Pour chacune, identifie la source du state qu'elle synchronise. Si c'est un BehaviorSubject, un HttpClient direct, un setInterval ou un Observer DOM, migre la source en signal. markForCheck s'évanouit avec.
  3. Ce qui reste (interop legacy explicite) devient ta liste de dette signal. Traite-la lot par lot.

Bonus zoneless : dès que tu as viré tous les markForCheck liés à des sources migrables, ta codebase est prête à passer provideZonelessChangeDetection(). Ce n'est pas un hasard. Le signal est déjà le mécanisme central de la détection ; zone.js n'apporte plus grand-chose.

ChangeDetectorRef reste dans le framework. Mais dans du code moderne, tu n'as plus à le voir.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

`outputFromObservable` et `outputToObservable` : les 2 ponts que ta migration `EventEmitter` → `output()` ignore

20 juillet 2026

Tu passes tes `@Output() EventEmitter` en `output()` et d'un coup ton `.pipe(debounceTime())` ne compile plus, ton service RxJS ne se branche plus, et tes tests hurlent. Les deux fonctions d'interop existent depuis Angular 17.3 (stables depuis la 19) mais 90% des migrations les ratent. Tour des 5 pièges qui cassent ton flux RxJS quand tu passes aux outputs signals, avec les patterns pour reprendre la main.

Angular Signals RxJS Composants Migration Bonnes pratiques

Signal `equal` : pourquoi ton `set()` avec un objet ne re-render rien (et les 5 pièges qui te bouffent la journée)

17 juillet 2026

Tu appelles set() avec un nouvel objet, ton composant ne bouge pas. Ou pire : tu appelles set() avec la même valeur, et Angular re-render quand même. La fonction equal des signals est piégeuse dès que tu quittes les primitives. Tour des 5 pièges qui sabotent tes re-renders et les patterns pour reprendre la main sans casser les perfs.

Angular Signals Réactivité Bonnes pratiques Performance

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.