useRef(null) semblait, à première vue, le bon outil pour garder une référence vers le bloc suivant après suppression d’un bloc personnalisé dans l’éditeur. Le bug qui en résultait, pourtant, n’apparaissait que dans un cas précis : la suppression du dernier bloc utilisant ce composant sur la page, un scénario suffisamment rare en test manuel pour être passé inaperçu pendant plusieurs semaines de développement.
Symptôme
Un bloc personnalisé, développé pour afficher une citation mise en forme dans l’éditeur, propose un bouton de suppression accessible au clavier. En cliquant ou en activant ce bouton avec la touche Entrée, le bloc disparaît correctement de l’éditeur. Le problème survient juste après : le focus clavier ne se retrouve nulle part de précis. La touche Tab suivante déplace le focus vers un élément de la barre d’outils supérieure de l’éditeur, sans lien logique avec l’endroit où l’utilisateur travaillait quelques secondes plus tôt.
Pour un utilisateur clavier, ce comportement casse totalement la continuité de la tâche : après une suppression, il doit chercher où se trouve désormais le focus avant de pouvoir reprendre son travail, une perte de repère que les utilisateurs voyants au clavier remarquent immédiatement et que les utilisateurs de lecteurs d’écran ne perçoivent parfois même pas, faute d’annonce du changement.
Diagnostic
Le composant utilisait une ref pour cibler le bloc suivant dans la liste, avec l’intention de lui donner le focus une fois le bloc courant supprimé.
function BlocCitation({ clientId, onSupprimer }) {
const refBlocSuivant = useRef(null);
useEffect(() => {
// Cette dépendance vide ne surveille jamais le changement réel
}, []);
function gererSuppression() {
onSupprimer(clientId);
if (refBlocSuivant.current) {
refBlocSuivant.current.focus();
}
}
return (
<div>
<button onClick={gererSuppression}>Supprimer la citation</button>
</div>
);
}
Le problème tient à deux éléments combinés. D’abord, refBlocSuivant.current est évaluée au moment de l’appel à gererSuppression, avant que React n’ait retiré le bloc courant du DOM et recalculé la liste des blocs restants : la référence pointe donc encore vers un état antérieur, ou vers rien du tout si le bloc supprimé était le dernier de la page. Ensuite, le tableau de dépendances vide de useEffect signifie que cet effet ne s’exécute qu’une seule fois au montage du composant, il ne peut donc jamais réagir à la disparition du bloc suivant dans une liste qui change dynamiquement.
Correctif
La correction déplace la logique de focus dans un effet qui surveille explicitement la liste des blocs, déclenché après que React a terminé de mettre à jour le DOM suite à la suppression.
function BlocCitation({ clientId, onSupprimer, blocsRestants }) {
const zoneEditeurRef = useRef(null);
useEffect(() => {
if (blocsRestants.length === 0 && zoneEditeurRef.current) {
zoneEditeurRef.current.focus();
}
}, [blocsRestants]);
function gererSuppression() {
onSupprimer(clientId);
}
return (
<div ref={zoneEditeurRef} tabIndex={-1}>
<button onClick={gererSuppression}>Supprimer la citation</button>
</div>
);
}
Le tableau de dépendances de useEffect surveille désormais blocsRestants, une donnée qui change réellement de référence à chaque suppression, ce qui déclenche l’effet au bon moment, après la mise à jour du DOM par React. Un conteneur parent reçoit un attribut tabIndex={-1}, ce qui le rend focalisable par script sans l’ajouter à l’ordre naturel de tabulation, une pratique courante pour offrir une cible de repli sûre quand aucun bloc suivant précis n’existe.

Prévention
- Ne jamais lire une
refimmédiatement après une action qui modifie l’état d’une liste : la valeur lue correspond souvent à l’état d’avant le rendu suivant. - Faire correspondre le tableau de dépendances de
useEffectà ce que l’effet surveille réellement, jamais un tableau vide par habitude ou par souci de performance mal placé. - Prévoir systématiquement une cible de repli pour le focus (le conteneur parent, la barre d’outils, le titre de la section) quand l’élément normalement ciblé peut ne plus exister après une suppression.
- Ajouter un test manuel spécifique à la suppression du dernier élément d’une liste, un scénario souvent oublié parce que moins fréquent en usage courant que la suppression d’un élément intermédiaire.
Un repère utile pour toute équipe travaillant sur des composants d’éditeur : tester systématiquement la suppression du premier élément, d’un élément intermédiaire et du dernier élément d’une liste. Les trois cas produisent souvent des comportements de focus différents, et seul le dernier révèle généralement ce type de bug.
En résumé
Ce bug ne touchait pas la logique de suppression du contenu elle-même, qui fonctionnait parfaitement : il touchait uniquement la synchronisation entre une référence DOM et le cycle de rendu de React. Une dépendance manquante dans un tableau de useEffect, un piège classique et bien documenté des hooks React, suffisait à laisser un utilisateur clavier sans repère après une action pourtant anodine.