Dans un SaaS de génération d'images, j'avais une page avec deux enfants : une barre latérale avec les contrôles et une boîte de dialogue de galerie. La page n'affichait rien en propre. C'était ceci :
export default function BoardPage() {
const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryFilters, setGalleryFilters] = useState<GalleryFilters>({
type: "all",
models: [],
});
return (
<div className="flex h-screen overflow-hidden">
<Sidebar attachedImages={attachedImages} ... />
Rien de cet état n'était utilisé par la page. Elle le déclarait parce qu'elle était l'ancêtre commun des deux qui l'utilisaient. C'est du prop-drilling dans sa forme la plus honnête : l'état remonte jusqu'à l'endroit où il gêne le moins, et redescend par une tuyauterie de props.
Le signal concret qui me fait migrer
Ce n'est pas « il y a beaucoup de props ». C'est plus précis : un composant reçoit une prop, ne la lit pas, et la passe telle quelle au suivant.
Dans ce board, la barre latérale recevait selectedModels et onModelsChange juste pour les renvoyer au popover de modèles. Elle ne les regardait pas. La page recevait galleryFilters de son propre useState juste pour les donner à la boîte de dialogue. Chaque fois que j'ajoutais un nouveau champ de génération, je touchais trois fichiers et aucun des trois n'avait de logique liée à ce champ.
Le second signal, dérivé du premier : les types de props deviennent un miroir de l'état. GalleryDialogProps avait quatre entrées — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — et aucune ne décrivait ce que fait la boîte de dialogue. Elles décrivaient comment on le lui raconte.
La migration a été quatre commits d'affilée en une après-midi. D'abord un use-board-store.ts avec la galerie et les images jointes, et la page qui le consomme. Ensuite les enfants qui lisent directement dans le store, et là la page est devenue ceci :
export default function BoardPage() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<GalleryDialog onImageClick={...} />
Zéro prop d'état. Le composant est redevenu ce qu'il prétendait être : une mise en page. Quand, au troisième commit, j'ai mis prompt, selectedAspectRatio, selectedModels et imageCount dans le store, la barre latérale a perdu 83 lignes et en a gagné 9.
Ce que je ne mets pas dans le store
La tentation après une migration comme ça, c'est de tout y mettre. Il ne faut pas, et le diff lui-même le montre.
Le popover de modèles avait quatre props : open, onOpenChange, selectedModels, onModelsChange. Seules deux sont parties au store. Les deux autres sont restées, mais elles sont descendues :
export function ModelsPopover() {
const { selectedModels, setSelectedModels } = useBoardStore();
const [open, setOpen] = useState(false);
Le critère, c'est qui a besoin de lire cette valeur. selectedModels est lu par le popover, par la barre latérale pour calculer le coût estimé et par le sélecteur de ratio pour savoir quelles proportions sont disponibles. open n'est lu par personne d'autre que le popover lui-même. Si je l'avais remonté au store, j'aurais cinq booléens xOpen dans l'état global sans aucune raison, et le bug du jour où deux resteront ouverts en même temps.
Je ne mets pas non plus dans le store ce qui est dérivable. Le calcul des proportions disponibles est l'intersection de celles que supporte chaque modèle sélectionné. C'est une fonction de selectedModels, donc ça vit là où c'est utilisé et ça se recalcule. Un champ availableAspectRatios dans le store serait une copie à maintenir synchronisée, et le jour où elle se désynchronise l'utilisateur verra un ratio que le modèle n'accepte pas.
- Au store : ce que lisent deux branches distinctes de l'arbre.
- Local au composant : l'éphémère d'une interaction — ouvert/fermé, survol, focus.
- Nulle part : ce qui se calcule à partir de ce qui précède.
L'éditeur de canvas, où l'arbre est plat et où il n'y a pourtant pas de store
À la même époque, je travaillais sur un éditeur de plans de bureaux, du genre Figma mais pour les postes de travail, basé sur Konva. Là, l'intuition dit « ça réclame un store à grands cris » : il y a un canevas, une barre d'outils, un panneau de propriétés et tous touchent aux mêmes formes.
Il n'y a pas de store. C'est toujours du useState dans le composant racine :
const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});
Et ça marche parce que l'arbre est plat. L'éditeur a trois enfants directs et chacun reçoit ce qu'il utilise vraiment. Personne ne réexpédie rien. Il remplit le critère ci-dessus, par le bon côté : aucun composant intermédiaire ne reçoit de props qu'il ne lit pas.
Ce qui a fait mal dans cet éditeur, ce n'était pas l'état, c'était l'explosion de types. Il y avait neuf variantes de poste comme types distincts : dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Neuf interfaces identiques au nom près, neuf branches dans le switch du canevas, neuf fichiers de composant. Le refactor qui a réglé ça les a réduites à deux, multi-desk-facing et multi-desk-parallel, avec un champ seats :
{Array.from({ length: seats }, (_, index) => (
<SingleDesk key={index} ... />
))}
Il a supprimé sept fichiers et retiré 453 lignes du composant qui dessine le plan dans la vue des réservations. Aucun store n'aurait réglé ça, parce que le problème n'a jamais été où vivait l'état mais combien de formes il avait.
Ce qui change quand l'état sort des props
On perd quelque chose de réel : en regardant la signature d'un composant, on ne sait plus de quoi il dépend. Il faut l'ouvrir. Les tests ne peuvent plus injecter d'état par les props et doivent préparer le store. Et disparaît la friction qui t'avertissait que tu couplais trop — la tuyauterie de props était moche, mais c'était un compteur honnête du couplage.
C'est pour ça que le store mérite sa place et qu'on ne la lui offre pas. La question n'est pas combien de props il y a. C'est de savoir si un composant du chemin en reçoit une qu'il n'utilise pas.
