~9 min de lecture
CDK Drag & Drop et signals : le drop marche à l'écran, l'autosave ne part jamais
Le ticket a l'air sympa pour une fois : "l'utilisateur doit pouvoir réordonner les étapes de son pipeline à la
souris". Tu connais la réponse : @angular/cdk/drag-drop, module éprouvé, doc propre, exemple qui marche du premier
coup. Tu copies le handler de la doc, tu l'adaptes à ton state en signals, tu testes : le drag est fluide, le drop se
pose exactement où il faut. Commit, push, démo vendredi.
Et lundi, le retour du client : "je réordonne, je reviens le lendemain, tout est revenu comme avant". L'écran disait
vrai, la base n'a jamais rien reçu. Ton autosave, branché sur un effect, n'a pas tourné une seule fois. Bienvenue
dans le piège le plus silencieux du CDK à l'ère des signals : des helpers qui mutent en place, face à un graphe
réactif qui compare par référence.
TL;DR
moveItemInArray et transferArrayItem mutent le tableau en place. Un signal.update() qui retourne la même
référence ne notifie personne : l'égalité par défaut d'un signal, c'est Object.is. Le piège est vicieux parce que
l'@for branché en direct sur le signal continue de suivre (le listener du template marque la vue à revérifier,
dirty, et le template relit le tableau muté), pendant que tout ce qui dépend de la notification reste figé :
computed(), effect(), les input() des enfants OnPush, toObservable(). Le fix tient en une ligne : copier
avant de muter. Ensuite : un handler qui reconstruit le state sans le muter pour le kanban (le transfert mute deux
tableaux), track sur l'identité, un plan B clavier, que le CDK ne fournit pas, et un faux piège : la preview
invisible sous une modale, un bug corrigé depuis le CDK 17.3.6.
Les exemples utilisent les signals et le control flow, et compilent tels quels à partir d'Angular 19 (standalone par défaut) ; en 17-18, ajoute
standalone: trueau décorateur, etinput()demande la 17.1 (developer preview avant la v19, stable en v19).effect(), utilisé pour l'autosave, est en developer preview de la v16 à la v19 et stable depuis la v20. Le module drag-drop du CDK est bien plus ancien ; installe simplement le@angular/cdkaligné sur ta version majeure d'Angular. Les comportements décrits ont été vérifiés en Angular 22 avec le CDK 22, dans Chromium.
Le setup qui a l'air propre
Une liste d'étapes, un signal, le handler adapté de l'exemple officiel :
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { PipelineApi } from './pipeline-api';
interface Step {
id: number;
label: string;
}
@Component({
selector: 'app-pipeline',
imports: [CdkDropList, CdkDrag],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul cdkDropList (cdkDropListDropped)="drop($event)">
@for (step of steps(); track step.id) {
<li cdkDrag>{{ step.label }}</li>
}
</ul>
`,
})
export class Pipeline {
private readonly api = inject(PipelineApi);
readonly steps = signal<Step[]>([
{ id: 1, label: 'Lead' },
{ id: 2, label: 'Qualification' },
{ id: 3, label: 'Proposition' },
{ id: 4, label: 'Signature' },
]);
constructor() {
// Autosave: persist the order every time it changes
effect(() => {
this.api.saveOrder(this.steps().map(step => step.id));
});
}
drop(event: CdkDragDrop<Step[]>) {
this.steps.update(steps => {
moveItemInArray(steps, event.previousIndex, event.currentIndex);
return steps;
});
}
}
Ça compile, ça passe le lint, le drag est fluide, le drop retombe au bon endroit. Et pourtant ce composant contient un bug de persistance que tu ne verras pas en dev, parce que l'écran, lui, dit la vérité.
Piège 1 : le signal muet, ou pourquoi l'@for suit mais pas l'effect
moveItemInArray réordonne directement le tableau qu'on lui passe et retourne void. Ton update
renvoie donc la même référence qu'il a reçue. Or un signal compare l'ancienne et la nouvelle valeur avec Object.is
par défaut : même référence, valeurs considérées égales, pas de notification. Pour tout le graphe réactif, il ne
s'est rien passé.
La partie vicieuse, c'est que l'@for continue de marcher. Un listener posé dans le template - événement DOM ou
output de directive comme (cdkDropListDropped) - marque la vue dirty avant d'appeler ton handler, en zone.js comme
en zoneless. La détection de changements repasse donc sur le template, relit steps(), et y trouve... le tableau
muté, dans son nouvel ordre. L'algorithme de comparaison de l'@for (le differ, qui confronte l'ancienne et la
nouvelle liste clé par clé) rapproche les clés de track et déplace les nœuds. Visuellement,
tout est parfait.
Pendant ce temps, tout ce qui dépend de la notification est figé :
- l'
effectd'autosave a tourné une fois à l'init, plus jamais : rien ne part en base ; - un
computed()dérivé (compteur, vue filtrée ou triée) garde sa valeur en cache ; - un enfant OnPush qui reçoit la liste en
input()ne voit rien passer : le binding du parent est comparé avecObject.islui aussi, même référence, la valeur n'est jamais retransmise ; toObservable(this.steps)n'émet pas, tondebounceTimen'a rien à temporiser.
Le symptôme de prod est exactement celui du retour client de lundi : l'utilisateur réordonne, l'écran confirme, il recharge, tout est
revenu. Variante différée : un refactor smart/dumb des mois plus tard fait passer la liste par un input(), et le
drag "qui marchait" se met à geler l'affichage sans qu'aucune ligne du refactor ne l'explique.
Le fix : copie avant de muter
drop(event: CdkDragDrop<Step[]>) {
this.steps.update(steps => {
const next = [...steps];
moveItemInArray(next, event.previousIndex, event.currentIndex);
return next;
});
}
Une copie superficielle suffit : les objets Step ne changent pas, c'est l'ordre qui change, et l'ordre est porté par
le tableau. Nouvelle référence, Object.is rend false, la notification part, effect, computed et enfants suivent.
Tu seras peut-être tenté par l'autre voie : signal(steps, { equal: () => false }) pour forcer la notification à
chaque écriture. Mauvaise idée : tu désactives la déduplication pour tous les consommateurs (chaque set, même sans
changement, réveille tout le graphe), et tu gardes la vraie dette, un state muté en place. Un champ de classe muté en
place marchait très bien avant les signals ; c'est la greffe naïve du même handler sur un signal qui casse.
Piège 2 : le kanban mute deux tableaux à la fois
Le cas qui fait le plus mal : un kanban, trois colonnes, drag entre colonnes. Le réflexe copié de la doc, c'est
transferArrayItem(previousContainer.data, container.data, previousIndex, currentIndex) - qui mute les deux
tableaux en place. Même piège, puissance deux.
Avec un state immutable en signal, le plus lisible n'est pas de copier les deux tableaux pour appeler le helper :
c'est d'écrire le transfert toi-même. Donne à chaque liste l'id de sa colonne via cdkDropListData, et reconstruis
le state :
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
CdkDrag,
CdkDragDrop,
CdkDropList,
CdkDropListGroup,
moveItemInArray,
} from '@angular/cdk/drag-drop';
interface Task {
id: number;
title: string;
}
interface Column {
id: string;
title: string;
tasks: Task[];
}
@Component({
selector: 'app-board',
imports: [CdkDropList, CdkDrag, CdkDropListGroup],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div cdkDropListGroup class="board">
@for (column of columns(); track column.id) {
<section
cdkDropList
[cdkDropListData]="column.id"
(cdkDropListDropped)="drop($event)"
>
<h2>{{ column.title }}</h2>
@for (task of column.tasks; track task.id) {
<article cdkDrag>{{ task.title }}</article>
}
</section>
}
</div>
`,
})
export class Board {
readonly columns = signal<Column[]>([
{ id: 'todo', title: 'À faire', tasks: [{ id: 1, title: 'Brancher l\'API' }] },
{ id: 'doing', title: 'En cours', tasks: [{ id: 2, title: 'Revoir le header' }] },
{ id: 'done', title: 'Terminé', tasks: [] },
]);
drop(event: CdkDragDrop<string>) {
const fromId = event.previousContainer.data;
const toId = event.container.data;
this.columns.update(columns => {
const from = columns.find(column => column.id === fromId)!;
const moved = from.tasks[event.previousIndex];
return columns.map(column => {
if (fromId === toId && column.id === fromId) {
const tasks = [...column.tasks];
moveItemInArray(tasks, event.previousIndex, event.currentIndex);
return { ...column, tasks };
}
if (column.id === fromId) {
return { ...column, tasks: column.tasks.filter((_, i) => i !== event.previousIndex) };
}
if (column.id === toId) {
const tasks = [...column.tasks];
tasks.splice(event.currentIndex, 0, moved);
return { ...column, tasks };
}
return column;
});
});
}
}
Deux détails qui t'économisent une heure :
cdkDropListGroupconnecte automatiquement toutes les listes qu'il contient, y compris celles créées par l'@for. L'alternativecdkDropListConnectedTomarche aussi avec des colonnes dynamiques (les ids sont résolus à chaque début de drag), mais t'oblige à maintenir la liste des ids connectés, par exemple via uncomputed;cdkDropListGroupt'en dispense.- La reconstruction (
{ ...column, tasks }) ne touche que les colonnes concernées : les autres gardent leur référence. Si chaque colonne est un enfant OnPush, seules celles qui ont bougé sont revérifiées. L'immutabilité n'est pas un tribut à payer, c'est elle qui rend l'OnPush rentable.
Besoin d'une règle métier ("pas plus de 3 tâches en cours") ? [cdkDropListEnterPredicate] refuse l'entrée d'un drag
dans une liste avant même le drop, avec le retour visuel qui va avec.
Le faux piège : la preview invisible sous ta modale, un bug corrigé depuis le CDK 17.3.6
Si tu as déjà fait glisser un élément invisible dans un <dialog> ouvert avec showModal(), pendant que le
placeholder (l'emplacement réservé que le CDK laisse dans la liste) bougeait "tout seul" : c'était le CDK d'avant.
La preview du drag, un clone de l'élément ajouté à document.body, restait sous le dialog, parce qu'un dialog modal
est rendu dans le top layer du navigateur, la couche qui peint au-dessus de tout le reste du document, z-index ou pas
(détaillée dans l'article sur la modale native).
Depuis le CDK 17.3.6, la preview est posée en popover (popover="manual" puis showPopover()) dès que le navigateur
supporte la Popover API : elle entre elle-même dans le top layer, au-dessus de ta modale. Autre effet du top layer :
le bloc conteneur (containing block) d'un élément qui y vit est le viewport, donc un transform sur un ancêtre ne
décale plus la preview. Si tu rencontres encore le bug, vérifie ta version du CDK avant de soupçonner ton CSS ;
cdkDragPreviewContainer="parent" reste la sortie de secours pour un vieux CDK que tu ne peux pas monter, à
condition qu'aucun ancêtre de la liste ne porte de transform.
Au passage, le drag se personnalise : *cdkDragPreview pour dessiner une vignette dédiée plutôt qu'un
clone, *cdkDragPlaceholder pour le trou laissé dans la liste, cdkDragHandle pour limiter la zone de prise à une
poignée.
Piège 3 : track $index défait ton drop
Après un drop, tu reconstruis le tableau : tous les items entre l'ancienne et la nouvelle position changent d'index.
Avec track $index, Angular ne déplace pas les nœuds DOM, il réécrit le contenu de ces lignes sur place. L'état DOM
qui n'est pas porté par un binding reste attaché à la position, pas à l'item : un input d'édition inline garde sa
saisie devant le mauvais item, et si le réordonnancement part des boutons clavier du piège 4, le focus reste sur la
même ligne, qui affiche maintenant une autre étape. track sur l'identité (step.id, task.id), jamais sur la position. C'est l'erreur n°1 de
l'article sur les 5 erreurs de track dans @for, avec les autres
façons dont track te joue des tours.
Piège 4 : le clavier n'existe pas
Le module drag-drop ne fournit aucune interaction clavier : cdkDrag écoute la souris et le tactile, point. Un
utilisateur au clavier ou au lecteur d'écran ne peut pas réordonner, et aucun attribut ARIA posé sur la liste ne le
corrigera : le problème n'est pas le nom accessible, c'est l'absence d'opération déclenchable sans pointeur.
Le pattern raisonnable : exposer la même opération via des boutons, et annoncer le résultat. Dans le Pipeline du
piège 1 :
import { LiveAnnouncer } from '@angular/cdk/a11y';
export class Pipeline {
// ... steps, drop() et l'effect d'autosave du piège 1
private readonly announcer = inject(LiveAnnouncer);
move(index: number, delta: -1 | 1) {
const steps = this.steps();
const target = index + delta;
if (target < 0 || target >= steps.length) return;
this.steps.update(current => {
const next = [...current];
moveItemInArray(next, index, target);
return next;
});
this.announcer.announce(`Étape ${steps[index].label} déplacée en position ${target + 1} sur ${steps.length}`);
}
}
Deux boutons monter/descendre par ligne, visibles au focus, qui appellent la même logique immutable que le drop. Le clavier n'est pas une deuxième feature à planifier "plus tard" : c'est une deuxième entrée vers la même mise à jour, une dizaine de lignes.
Récap actionnable
moveItemInArrayettransferArrayItemmutent en place : copie avant de muter (const next = [...list]), ou écris le transfert immutable à la main pour le cross-colonnes.- Le symptôme du signal muet : l'
@forqui lit le signal directement suit, maiseffect,computed,input()enfants ettoObservablerestent figés. Un autosave qui ne part pas alors que l'écran est juste, c'est une mutation en place quelque part. - Pas de
equal: () => falsepour "réparer" : tu perds la déduplication sur tout le graphe et tu gardes le state muté. - Kanban :
cdkDropListGroup+cdkDropListDataavec l'id de colonne, reconstruction immutable qui ne touche que les colonnes concernées,cdkDropListEnterPredicatepour les règles métier. - La preview est un popover du top layer depuis le CDK 17.3.6, sur un navigateur qui supporte la Popover API : plus
de preview invisible sous une modale, plus de décalage par un
transformd'ancêtre. Si tu vois encore le bug, regarde ta version du CDK. tracksur l'identité, jamais$index: un drop décale les index de toute la plage déplacée.- Le CDK ne fournit aucune interaction clavier : boutons monter/descendre branchés sur la même logique, plus
LiveAnnouncerpour annoncer la nouvelle position.
Le drag and drop du CDK est solide ; ce qui casse, c'est surtout la rencontre entre ses helpers d'avant les signals
et ton graphe réactif d'aujourd'hui, plus un angle mort à connaître : le clavier absent. Retiens le réflexe : dans un
update, si tu n'as pas créé de nouvelle référence, tu n'as rien mis à jour.