~10 min de lecture

Drag & drop CDK + signals : le bug qui remet ton item à sa place

Tu ajoutes un drag & drop sur ta liste de tâches. Le CDK fait le gros du travail : cdkDropList, cdkDrag, un handler sur cdkDropListDropped, et en dix minutes ça glisse. L'item suit la souris, les autres s'écartent, l'animation est propre. Tu lâches... et l'item se téléporte à sa position d'origine. Pas d'erreur console, pas de warning. Le drag marche, le drop a l'air de ne rien faire.

Tu relis ton handler : il appelle bien moveItemInArray dans un update() de signal, il retourne bien le tableau. Plus troublant : dans le composant jetable où tu avais prototypé, ce même handler marchait. Tout est juste sauf une ligne, et cette ligne ne casse que certains montages. Le tien, en l'occurrence.


TL;DR

moveItemInArray et transferArrayItem mutent le tableau en place et retournent void. Si ton update() retourne la même référence, le signal ne notifie rien (égalité référentielle) : dès que le rendu passe par un computed qui construit un nouveau tableau (filtre, tri, map) ou par l'input d'un enfant OnPush, l'item revient à sa place alors que tes données sont déjà réordonnées en coulisse ; un effect qui lit la liste, lui, ne se relance pas. Un @for branché directement sur le signal a l'air de marcher : il relit le tableau muté à chaque rendu. Accident, pas absolution. Le fix : copie le tableau avant de le passer au CDK. Les deux autres pièges : trier dans un computed une liste que l'utilisateur réordonne (un ordre choisi à la main est un état : une clé d'ordre que le handler réécrit, ou un signal writable comme linkedSignal), et connecter deux listes, où transferArrayItem mute deux tableaux d'un coup.

Exemples vérifiés en Angular 22.2 avec @angular/cdk 22.2. Le piège et le fix valent dès Angular 17 ; seul l'exemple linkedSignal demande la v19 (API stabilisée en v20).


Le setup qui a l'air propre

Une liste de tâches, un filtre "masquer les terminées" dans un computed, le CDK. Du code de tous les jours :

// task-list.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  signal,
} from '@angular/core';
import {
  CdkDrag,
  CdkDragDrop,
  CdkDropList,
  moveItemInArray,
} from '@angular/cdk/drag-drop';

interface Task {
  id: number;
  title: string;
  done: boolean;
}

@Component({
  selector: 'app-task-list',
  imports: [CdkDropList, CdkDrag],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul cdkDropList (cdkDropListDropped)="drop($event)">
      @for (task of visible(); track task.id) {
        <li cdkDrag>{{ task.title }}</li>
      }
    </ul>
  `,
})
export class TaskList {
  readonly tasks = signal<Task[]>([
    { id: 1, title: 'Payer la facture', done: false },
    { id: 2, title: 'Relancer le client', done: false },
    { id: 3, title: 'Déployer en prod', done: false },
  ]);

  readonly visible = computed(() => this.tasks().filter((t) => !t.done));

  drop(event: CdkDragDrop<Task[]>) {
    this.tasks.update((tasks) => {
      moveItemInArray(tasks, event.previousIndex, event.currentIndex);
      return tasks;
    });
  }
}

Le handler ressemble à un reducer : il reçoit l'état, le transforme, le retourne. Sauf qu'ici, il retourne la même référence qu'il a reçue. Avec ce montage, l'item revient à sa place à chaque drop.

Piège 1 : pourquoi l'item revient à sa place

Trois mécaniques se combinent, chacune raisonnable isolément.

Côté CDK : moveItemInArray(array, from, to) mute le tableau en place et retourne void. Pendant le drag, le CDK déplace visuellement les éléments, par défaut avec des transforms CSS. Au drop, il nettoie tout : la preview (la copie de l'élément qui suit le pointeur), le placeholder (l'emplacement réservé dans la liste) et les transforms. Il ne réordonne jamais ton DOM définitivement : il attend que tes données changent, car Angular reste seul maître du rendu.

Côté signals : un signal ne notifie ses dépendants que si la nouvelle valeur est différente, et pour un objet, "différente" veut dire par défaut une autre référence (égalité Object.is, personnalisable via equal). Même référence, donc aucun changement aux yeux du signal, donc visible n'est pas invalidé.

Côté rendu : le drop marque quand même la vue à rafraîchir (listener d'événement, plus un markForCheck() du CDK). Angular refait donc le rendu de la liste... en relisant visible(), qui sert depuis son cache le tableau filtré construit avant la mutation. Le DOM réaffiche l'ancien ordre, sans la moindre erreur. L'item revient à sa place.

Tu peux prouver le cœur du problème en quelques lignes, sans navigateur :

// À lancer tel quel avec tsx ou dans un test Vitest. Hors d'une appli
// Angular, la ligne ci-dessous est requise : le CDK charge des
// injectables compilés à la volée.
import '@angular/compiler';

import { computed, signal } from '@angular/core';
import { moveItemInArray } from '@angular/cdk/drag-drop';

const tasks = signal(['a', 'b', 'c']);
let computations = 0;
const view = computed(() => {
  computations++;
  return tasks().join(' | ');
});

console.log(view()); // "a | b | c" (computations = 1)

tasks.update((list) => {
  moveItemInArray(list, 0, 2); // mute en place, retourne void
  return list; // même référence
});

console.log(view()); // "a | b | c" (computations = 1, pas recalculé)
console.log(tasks().join(' | ')); // "b | c | a" !

Relis les deux dernières lignes. Le computed sert l'ancienne valeur depuis son cache, mais une lecture directe du signal montre le nouvel ordre. Tes données ont bougé, ta vue non. Un effect() qui persisterait l'ordre ne se relance pas au drop : rien ne part vers le serveur, jusqu'à la prochaine nouvelle référence, qui enverra d'un coup l'ordre accumulé en coulisse.

C'est là que le bug devient vicieux. L'utilisateur voit son item revenir et recommence ; les index de l'événement décrivent l'ordre périmé qu'il a sous les yeux, et ton handler les applique au tableau déjà réordonné. Chaque tentative empile un déplacement de plus. À la première nouvelle référence (un ajout de tâche, un refresh), la liste apparaît dans un ordre que personne n'a jamais composé. Et le ticket qui en sortira ne mentionnera même pas le drag & drop.

Pourquoi ton prototype, lui, marchait

Dans ta version jetable, le @for itérait directement sur tasks(), sans filtre, et le DOM se réordonnait. L'explication : au rafraîchissement déclenché par le listener, le @for relit tasks() et réconcilie la collection (il compare les clés de track des items à celles du rendu précédent pour décider quels nœuds déplacer, créer ou supprimer) sans comparer la référence du tableau. Le tableau étant muté en place, le nouvel ordre atterrit dans le DOM. Ça marche, mais par accident : rien de ce qui garde un résultat en cache (un computed, un enfant OnPush) n'a été prévenu, et le jour où tu extrais une vue dérivée ou un composant de présentation, le drop "casse" sans qu'une ligne du handler ait changé. La mutation n'était pas correcte, elle n'avait pas encore trouvé son témoin.

Le fix : copie avant de bouger

Puisque le CDK mute et que le signal compare les références, donne au CDK une copie et retourne la copie :

drop(event: CdkDragDrop<Task[]>) {
  this.tasks.update((tasks) => {
    const next = [...tasks];
    moveItemInArray(next, event.previousIndex, event.currentIndex);
    return next;
  });
}

Nouvelle référence, le signal notifie, visible recalcule son filtre, le @for refait son rendu : l'item reste où l'utilisateur l'a lâché, et un éventuel effect de persistance se déclenche enfin. Une copie superficielle suffit : les objets Task eux-mêmes ne bougent pas, seul leur ordre change.

Un détail propre au montage filtré : les index de l'événement décrivent la vue. Dès que le filtre masque des items, traduis-les avant le moveItemInArray : repère les items de départ et d'arrivée dans visible(), puis retrouve leurs index dans tasks() (par indexOf ou par id).

Au passage : il n'y a pas de mutate() sur les signals, l'API a été retirée avant la v17 stable au profit d'update() et de mises à jour immuables. L'immuabilité des valeurs objet n'est pas une préférence de style, c'est le contrat qui fait marcher la notification.

Et garde ton track task.id : avec une nouvelle référence mais les mêmes objets dedans, Angular déplace les nœuds DOM existants au lieu de les recréer. Un track $index sur une liste réordonnable, c'est réécrire le contenu de chaque ligne à chaque drop : les nœuds restent en place et l'état local (focus, saisie en cours) se retrouve rattaché à la mauvaise ligne.

Piège 2 : un ordre fait à la main ne sort pas d'un tri

Deuxième situation classique : ta liste affichée est triée dans une dérivation.

readonly tasks = signal<Task[]>([]);
readonly sorted = computed(() =>
  [...this.tasks()].sort((a, b) => a.title.localeCompare(b.title)),
);

Le template itère sur sorted(), tu branches le drag & drop dessus... et le drop ne peut pas tenir : le handler n'a aucun état à écrire pour exprimer "cet item va ici". Le tri par titre recalcule l'ordre à chaque nouvelle valeur : même avec la copie du fix, l'item glissé saute aussitôt à la position que le tri lui assigne.

Le vrai problème est conceptuel : un ordre choisi à la main est un état, pas une dérivation. Il doit vivre dans quelque chose que le handler peut écrire, ce qui laisse deux montages légitimes. Soit le tri repose sur une clé que le handler réécrit (un champ position ou rank) : le drop se traduit en réécriture des clés et le computed re-trie dans l'ordre voulu. C'est le schéma classique de l'ordre persisté. Soit l'ordre est la liste elle-même, dans un signal writable.

Le cas le plus courant de ce second montage : la liste vient du serveur (ou d'un input()), et tu veux la laisser réordonnable localement. C'est la fiche de poste de linkedSignal : writable au quotidien, recalculé quand sa source change.

// sortable-list.ts
import {
  ChangeDetectionStrategy,
  Component,
  input,
  linkedSignal,
} from '@angular/core';
import {
  CdkDrag,
  CdkDragDrop,
  CdkDropList,
  moveItemInArray,
} from '@angular/cdk/drag-drop';

interface Task {
  id: number;
  title: string;
}

@Component({
  selector: 'app-sortable-list',
  imports: [CdkDropList, CdkDrag],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul cdkDropList (cdkDropListDropped)="drop($event)">
      @for (task of ordered(); track task.id) {
        <li cdkDrag>{{ task.title }}</li>
      }
    </ul>
  `,
})
export class SortableList {
  readonly items = input.required<Task[]>();

  // writable pour le drop, reset quand l'input change
  readonly ordered = linkedSignal(() => this.items());

  drop(event: CdkDragDrop<Task[]>) {
    this.ordered.update((tasks) => {
      const next = [...tasks];
      moveItemInArray(next, event.previousIndex, event.currentIndex);
      return next;
    });
  }
}

Pour un tri initial, mets-le dans la computation avec ton comparateur : linkedSignal(() => [...this.items()].sort(byTitle)). La liste part triée, puis l'utilisateur la réordonne librement jusqu'au prochain changement de source.

Si l'ordre doit survivre au refresh, garde la mise à jour locale et envoie le nouvel ordre au serveur dans le handler. Mais sache que la prochaine valeur de la source écrasera l'ordre local : si le serveur renvoie l'ancien ordre, l'item revient à sa place. Le prochain refresh doit donc relire un ordre déjà enregistré : attends la réponse du serveur avant de recharger, ou fais renvoyer la liste à jour par l'appel de persistance.

Piège 3 : deux listes, deux signals, une mutation

Le kanban maintenant : deux colonnes connectées par cdkDropListGroup (posé sur un parent, il connecte toutes les drop lists enfants), et une tâche qui glisse de "à faire" vers "fait". Le CDK fournit transferArrayItem, qui a le même défaut que moveItemInArray : il mute, mais cette fois deux tableaux d'un coup, et retourne lui aussi void.

Le pattern propre avec des signals : passer le signal lui-même dans cdkDropListData. L'événement te rend alors le bon signal pour chaque container, sans aiguillage manuel :

// task-board.ts
import {
  ChangeDetectionStrategy,
  Component,
  signal,
  WritableSignal,
} from '@angular/core';
import {
  CdkDrag,
  CdkDragDrop,
  CdkDropList,
  CdkDropListGroup,
  moveItemInArray,
  transferArrayItem,
} from '@angular/cdk/drag-drop';

interface Task {
  id: number;
  title: string;
}

@Component({
  selector: 'app-task-board',
  imports: [CdkDropList, CdkDrag, CdkDropListGroup],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div cdkDropListGroup class="board">
      <ul
        cdkDropList
        [cdkDropListData]="todo"
        (cdkDropListDropped)="drop($event)"
      >
        @for (task of todo(); track task.id) {
          <li cdkDrag>{{ task.title }}</li>
        }
      </ul>
      <ul
        cdkDropList
        [cdkDropListData]="done"
        (cdkDropListDropped)="drop($event)"
      >
        @for (task of done(); track task.id) {
          <li cdkDrag>{{ task.title }}</li>
        }
      </ul>
    </div>
  `,
})
export class TaskBoard {
  readonly todo = signal<Task[]>([
    { id: 1, title: 'Payer la facture' },
    { id: 2, title: 'Relancer le client' },
  ]);
  readonly done = signal<Task[]>([{ id: 3, title: 'Déployer en prod' }]);

  drop(event: CdkDragDrop<WritableSignal<Task[]>>) {
    const source = event.previousContainer.data;
    const target = event.container.data;

    if (source === target) {
      target.update((tasks) => {
        const next = [...tasks];
        moveItemInArray(next, event.previousIndex, event.currentIndex);
        return next;
      });
      return;
    }

    const from = [...source()];
    const to = [...target()];
    transferArrayItem(from, to, event.previousIndex, event.currentIndex);
    source.set(from);
    target.set(to);
  }
}

Note le [cdkDropListData]="todo" sans parenthèses : on passe le signal, pas sa valeur. Le CDK ne lit jamais data pour rendre quoi que ce soit, il te le restitue dans l'événement. Tu copies les deux tableaux, tu laisses transferArrayItem muter les copies, tu écris les deux signals : deux nouvelles références, deux colonnes mises à jour. Passer la valeur (todo()) marcherait aussi, mais avec un aiguillage manuel pour retrouver quel container correspond à quel signal : plus fragile, zéro gain.

Avant de livrer : deux angles morts

Le clavier. Le drag & drop du CDK est réservé au pointeur : aucune interaction clavier fournie, donc une liste réordonnable uniquement à la souris est inutilisable pour une partie de tes utilisateurs. Prévois des boutons monter/descendre ou des raccourcis branchés sur la même logique.

Les tests. Le fix du piège 1 se teste sans DOM : appelle ton handler avec un faux CdkDragDrop (seuls previousIndex et currentIndex comptent) et vérifie que le signal expose le nouvel ordre et une nouvelle référence. C'est le test qui aurait attrapé le bug de cet article.

Récap actionnable

  • moveItemInArray et transferArrayItem mutent en place et retournent void : jamais directement sur le tableau d'un signal.
  • Dans le handler de drop : copie, mute la copie, retourne la copie. Nouvelle référence = notification = re-render.
  • Symptômes : l'item revient à sa place dès que le rendu passe par un computed qui construit un nouveau tableau ou par l'input d'un enfant OnPush, et un éventuel effect de persistance ne part pas au drop. Cherche un update() qui retourne son argument.
  • Un @for branché directement sur le signal masque le bug : il relit le tableau muté à chaque rendu. Par accident, jusqu'au premier refactor.
  • Un ordre modifiable à la main est un état : signal writable (linkedSignal si la source est externe), ou clé d'ordre que le handler réécrit. Pas un tri sur une clé que l'utilisateur ne contrôle pas.
  • Listes connectées : passe les signals dans cdkDropListData, copie les deux tableaux, écris les deux signals.
  • Garde track task.id : c'est lui qui transforme un re-render de liste en simple déplacement de nœuds DOM.
  • Le CDK ne gère pas le clavier : fournis une alternative accessible qui réutilise la même logique.

📧 Reste informé(e) !

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

S'inscrire gratuitement

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.