In een SaaS voor beeldgeneratie had ik een pagina met twee kinderen: een zijbalk met de bediening en een galerijdialoog. De pagina tekende zelf niets. Het was dit:
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} ... />
Niets van die state gebruikte de pagina zelf. Hij declareerde die omdat hij de gemeenschappelijke voorouder was van de twee die hem wel gebruikten. Dat is prop-drilling in zijn meest eerlijke vorm: de state stijgt tot waar hij het minst in de weg zit en daalt weer af via een pijplijn van props.
Het concrete signaal dat me doet migreren
Het is niet "er zijn veel props". Het is specifieker: een component krijgt een prop, leest hem niet, en geeft hem in zijn geheel door aan de volgende.
In dat board kreeg de zijbalk selectedModels en onModelsChange alleen om ze door te sturen naar de modellenpopover. Hij keek er niet naar. De pagina kreeg galleryFilters uit zijn eigen useState alleen om ze aan de dialoog te geven. Elke keer dat ik een nieuw generatieveld toevoegde, moest ik drie bestanden aanraken en geen van de drie had logica die met dat veld te maken had.
Het tweede signaal, afgeleid van het eerste: de proptypes worden een spiegel van de state. GalleryDialogProps had vier ingangen — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — en geen daarvan beschreef wat de dialoog doet. Ze beschreven hoe je het hem vertelt.
De migratie waren vier opeenvolgende commits op één middag. Eerst een use-board-store.ts met de galerij en de bijgevoegde afbeeldingen, en de pagina die die consumeert. Daarna de kinderen die rechtstreeks uit de store lezen, en toen zag de pagina er zo uit:
export default function BoardPage() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<GalleryDialog onImageClick={...} />
Nul state-props. De component werd weer wat hij zei te zijn: een layout. Toen ik in de derde commit prompt, selectedAspectRatio, selectedModels en imageCount in de store zette, verloor de zijbalk 83 regels en won er 9.
Wat ik niet in de store stop
De verleiding na zo'n migratie is om alles erin te stoppen. Dat moet je niet doen, en de diff zelf laat dat zien.
De modellenpopover had vier props: open, onOpenChange, selectedModels, onModelsChange. Maar twee ervan gingen naar de store. De andere twee bleven, maar daalden van plek:
export function ModelsPopover() {
const { selectedModels, setSelectedModels } = useBoardStore();
const [open, setOpen] = useState(false);
Het criterium is wie die waarde nog meer moet lezen. selectedModels wordt gelezen door de popover, door de zijbalk om de geschatte kosten te berekenen, en door de ratiokiezer om te weten welke verhoudingen beschikbaar zijn. open wordt door niemand anders gelezen dan de popover zelf. Had ik die naar de store getild, dan had ik vijf xOpen-booleans in de globale state zonder enige reden, plus de bug van de dag dat er twee tegelijk open blijven staan.
Ik stop ook niets afleidbaars in de store. De berekening van beschikbare verhoudingen is de doorsnede van wat elk geselecteerd model ondersteunt. Het is een functie van selectedModels, dus hij leeft waar hij wordt gebruikt en wordt herberekend. Een veld availableAspectRatios in de store zou een kopie zijn die je gesynchroniseerd moet houden, en de dag dat die uit de pas loopt, ziet de gebruiker een ratio die het model niet accepteert.
- Naar de store: wat twee verschillende takken van de boom lezen.
- Lokaal in de component: het vluchtige van een interactie — open/dicht, hover, focus.
- Nergens: wat je uit het bovenstaande kunt berekenen.
De canvas-editor, waar de boom plat is en er toch geen store is
In diezelfde periode werkte ik aan een editor voor kantoorplattegronden, Figma-achtig maar voor bureaus, bovenop Konva. Daar zegt de intuïtie "dit schreeuwt om een store": er is een canvas, een werkbalk, een eigenschappenpaneel en ze raken allemaal dezelfde vormen aan.
Er zit geen store in. Het is nog steeds useState in de rootcomponent:
const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});
En het werkt omdat de boom plat is. De editor heeft drie directe kinderen en elk krijgt wat het echt gebruikt. Niemand stuurt iets door. Hij voldoet aan het criterium hierboven, maar dan aan de goede kant: er is geen enkele tussenliggende component die props krijgt die hij niet leest.
Wat in die editor wel pijn deed, was niet de state maar de explosie van types. Er waren negen bureauvarianten als aparte types: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Negen identieke interfaces op de naam na, negen takken in de switch van het canvas, negen componentbestanden. De refactor die dat oploste, klapte ze samen tot twee, multi-desk-facing en multi-desk-parallel, met een veld seats:
{Array.from({ length: seats }, (_, index) => (
<SingleDesk key={index} ... />
))}
Het verwijderde zeven bestanden en haalde 453 regels weg uit de component die de plattegrond in het reserveringsscherm tekent. Geen enkele store had dat opgelost, want het probleem was nooit waar de state woonde maar hoeveel vormen die had.
Wat er verandert als de state uit de props verdwijnt
Je verliest iets echts: aan de signatuur van een component zie je niet meer waar hij van afhangt. Je moet hem openen. Tests kunnen geen state meer injecteren via props en moeten de store voorbereiden. En de wrijving verdwijnt die je waarschuwde dat je te veel aan het koppelen was — de pijplijn van props was lelijk, maar was een eerlijke teller van de koppeling.
Daarom verdient de store zijn plek en krijgt hij die niet cadeau. De vraag is niet hoeveel props er zijn. De vraag is of een component onderweg er een krijgt die hij niet gebruikt.
