~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" :
- Un input reçoit une nouvelle référence
- Un event bindé dans le template (
(click),(keydown)...) déclenche - Un
AsyncPipeémet - Quelqu'un appelle explicitement
markForCheck()sur sonChangeDetectorRef
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 :
git grep 'markForCheck': recense chaque occurrence.- Pour chacune, identifie la source du state qu'elle synchronise. Si c'est un
BehaviorSubject, unHttpClientdirect, unsetIntervalou unObserverDOM, migre la source en signal.markForChecks'évanouit avec. - 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.