In un SaaS di generazione di immagini avevo una pagina con due figli: una barra laterale con i controlli e un dialogo di galleria. La pagina non disegnava niente di suo. Era questo:
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} ... />
Niente di quello stato lo usava la pagina. Lo dichiarava perché era l'antenato comune dei due che invece lo usavano. Questo è prop-drilling nella sua forma più onesta: lo stato sale fino a dove dà meno fastidio, e scende per un tubo di prop.
Il segnale concreto che mi fa migrare
Non è "ci sono tante prop". È più specifico: un componente riceve una prop, non la legge, e la passa intera al successivo.
In quella board la barra laterale riceveva selectedModels e onModelsChange solo per rigirarli al popover dei modelli. Non li guardava. La pagina riceveva galleryFilters dal suo stesso useState solo per darli al dialogo. Ogni volta che aggiungevo un campo di generazione nuovo toccavo tre file e nessuno dei tre aveva logica che riguardasse quel campo.
Il secondo segnale, derivato dal primo: i tipi delle prop diventano uno specchio dello stato. GalleryDialogProps aveva quattro voci — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — e nessuna descriveva quello che il dialogo fa. Descrivevano come glielo raccontano.
La migrazione sono stati quattro commit di fila in un pomeriggio. Prima uno use-board-store.ts con la galleria e le immagini allegate, e la pagina che lo consuma. Poi i figli che leggono dallo store direttamente, e lì la pagina è rimasta così:
export default function BoardPage() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<GalleryDialog onImageClick={...} />
Zero prop di stato. Il componente è tornato a essere quello che diceva di essere: un layout. Quando al terzo commit ho messo prompt, selectedAspectRatio, selectedModels e imageCount nello store, la barra laterale ha perso 83 righe e ne ha guadagnate 9.
Cosa non metto nello store
La tentazione dopo una migrazione così è metterci tutto. Non bisogna farlo, e il diff stesso lo insegna.
Il popover dei modelli aveva quattro prop: open, onOpenChange, selectedModels, onModelsChange. Solo due sono finite nello store. Le altre due sono rimaste, ma sono scese di posto:
export function ModelsPopover() {
const { selectedModels, setSelectedModels } = useBoardStore();
const [open, setOpen] = useState(false);
Il criterio è chi ha più bisogno di leggere quel valore. selectedModels lo legge il popover, lo legge la barra laterale per calcolare il costo stimato e lo legge il selettore di proporzioni per sapere quali rapporti sono disponibili. open non lo legge nessuno tranne il popover stesso. Se l'avessi portato nello store avrei cinque booleani xOpen nello stato globale senza nessuna ragione, e il bug del giorno in cui due restano aperti insieme.
Nello store non metto nemmeno niente di derivabile. Il calcolo delle proporzioni disponibili è l'intersezione di quelle che supporta ogni modello selezionato. È una funzione di selectedModels, quindi vive dove si usa e si ricalcola. Un campo availableAspectRatios nello store sarebbe una copia da tenere sincronizzata, e il giorno in cui si desincronizza l'utente vedrà un rapporto che il modello non accetta.
- Nello store: quello che leggono due rami distinti dell'albero.
- Locale al componente: l'effimero di un'interazione — aperto/chiuso, hover, focus.
- In nessun posto: quello che si può calcolare a partire dal resto.
L'editor di canvas, dove l'albero è piatto e comunque non c'è store
Nello stesso periodo stavo lavorando a un editor di planimetrie di ufficio, tipo Figma ma per scrivanie, sopra Konva. Lì l'intuito dice "questo chiede uno store a gran voce": c'è una tela, una barra degli strumenti, un pannello delle proprietà e tutti toccano le stesse figure.
Non ha uno store. È ancora useState nel componente radice:
const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});
E funziona perché l'albero è piatto. L'editor ha tre figli diretti e ognuno riceve quello che usa davvero. Nessuno rigira niente. Soddisfa il criterio di sopra dal lato buono: non c'è nessun componente intermedio che riceva prop che non legge.
Quello che invece ha fatto male in quell'editor non è stato lo stato, è stata l'esplosione di tipi. C'erano nove varianti di scrivania come tipi distinti: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Nove interfacce identiche tranne il nome, nove rami nello switch della tela, nove file di componente. Il refactor che l'ha sistemato le ha collassate in due, multi-desk-facing e multi-desk-parallel, con un campo seats:
{Array.from({ length: seats }, (_, index) => (
<SingleDesk key={index} ... />
))}
Ha cancellato sette file e tolto 453 righe dal componente che disegna la planimetria nella vista delle prenotazioni. Nessuno store avrebbe risolto quello, perché il problema non è mai stato dove viveva lo stato ma quante forme aveva.
Cosa cambia quando lo stato esce dalle prop
Si perde qualcosa di reale: guardando la firma di un componente non sai più da cosa dipende. Bisogna aprirlo. I test non possono più iniettare stato tramite prop e devono preparare lo store. E sparisce l'attrito che ti avvisava che stavi accoppiando troppo — il tubo di prop era brutto, ma era un contatore onesto dell'accoppiamento.
Per questo lo store il posto se lo guadagna e non glielo si regala. La domanda non è quante prop ci sono. È se qualche componente lungo il percorso ne riceve una che non usa.
