02Frontend5 min

Quan un store global es guanya el seu lloc

L'estat global no es justifica per comoditat. Es justifica quan un component intermedi rep props que no fa servir, només per passar-les cap avall.

Per Salva Castells

Imatge de portada: Fabian Horst, CC BY-SA 4.0.
En aquest post

En un SaaS de generació d'imatges tenia una pàgina amb dos fills: una barra lateral amb els controls i un diàleg de galeria. La pàgina no pintava res propi. Era això:

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} ... />

Res d'aquell estat no el feia servir la pàgina. El declarava perquè era l'avantpassat comú dels dos que sí que el feien servir. Això és prop-drilling en la seva forma més honesta: l'estat puja fins on molesta menys, i baixa per una canonada de props.

El senyal concret que em fa migrar

No és "hi ha moltes props". És més específic: un component rep una prop, no la llegeix, i la passa sencera al següent.

En aquell board la barra lateral rebia selectedModels i onModelsChange només per reenviar-los al popover de models. No els mirava. La pàgina rebia galleryFilters del seu propi useState només per donar-los al diàleg. Cada vegada que afegia un camp de generació nou tocava tres fitxers i cap dels tres no tenia lògica relacionada amb aquell camp.

El segon senyal, derivat del primer: els tipus de props es converteixen en un mirall de l'estat. GalleryDialogProps tenia quatre entrades —galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange— i cap no descrivia el que fa el diàleg. Descrivien com l'hi expliquen.

La migració van ser quatre commits seguits en una tarda. Primer un use-board-store.ts amb la galeria i les imatges adjuntes, i la pàgina consumint-lo. Després els fills llegint del store directament, i allà la pàgina va quedar així:

export default function BoardPage() {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <GalleryDialog onImageClick={...} />

Zero props d'estat. El component va tornar a ser el que deia ser: un layout. Quan al tercer commit vaig ficar prompt, selectedAspectRatio, selectedModels i imageCount al store, la barra lateral va perdre 83 línies i en va guanyar 9.

El que no fico al store

La temptació després d'una migració així és ficar-ho tot. No s'ha de fer, i el mateix diff ho ensenya.

El popover de models tenia quatre props: open, onOpenChange, selectedModels, onModelsChange. Només dues se'n van anar al store. Les altres dues es van quedar, però van baixar de lloc:

export function ModelsPopover() {
  const { selectedModels, setSelectedModels } = useBoardStore();
  const [open, setOpen] = useState(false);

El criteri és qui més necessita llegir aquell valor. selectedModels el llegeix el popover, el llegeix la barra lateral per calcular el cost estimat i el llegeix el selector de ràtio per saber quines proporcions estan disponibles. open no el llegeix ningú més que el mateix popover. Si l'hagués pujat al store tindria cinc booleans xOpen a l'estat global sense cap raó, i el bug del dia que dos es quedin oberts alhora.

Tampoc no fico al store res derivable. El càlcul de proporcions disponibles és la intersecció de les que suporta cada model seleccionat. És una funció de selectedModels, així que viu on es fa servir i es recalcula. Un camp availableAspectRatios al store seria una còpia que cal mantenir sincronitzada, i el dia que es desincronitzi l'usuari veurà un ràtio que el model no accepta.

  • Al store: el que llegeixen dues branques diferents de l'arbre.
  • Local al component: l'efímer d'una interacció — obert/tancat, hover, focus.
  • Enlloc: el que es pot calcular a partir de l'anterior.

L'editor de canvas, on l'arbre és pla i tot i així no hi ha store

Per la mateixa època estava amb un editor de plànols d'oficina, tipus Figma però per a escriptoris, sobre Konva. Allà la intuïció diu "això demana store a crits": hi ha un llenç, una barra d'eines, un panell de propietats i tots toquen les mateixes figures.

No porta store. Continua sent useState al component arrel:

const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
  selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});

I funciona perquè l'arbre és pla. L'editor té tres fills directes i cadascun rep el que de debò fa servir. Ningú no reenvia res. Compleix el criteri de més amunt pel costat bo: no hi ha cap component intermedi que rebi props que no llegeix.

El que sí que va fer mal en aquell editor no va ser l'estat, va ser l'explosió de tipus. Hi havia nou variants d'escriptori com a tipus diferents: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Nou interfícies idèntiques tret del nom, nou branques al switch del llenç, nou fitxers de component. La refactorització que ho va arreglar les va col·lapsar en dues, multi-desk-facing i multi-desk-parallel, amb un camp seats:

{Array.from({ length: seats }, (_, index) => (
  <SingleDesk key={index} ... />
))}

Va esborrar set fitxers i va treure 453 línies del component que pinta el plànol a la vista de reserves. Cap store no hauria arreglat això, perquè el problema mai no va ser on vivia l'estat sinó quantes formes tenia.

El que canvia quan l'estat surt de les props

Es perd una cosa real: mirant la signatura d'un component ja no saps de què depèn. Cal obrir-lo. Els tests deixen de poder injectar estat per props i han de preparar el store. I desapareix la fricció que t'avisava que estaves acoblant de més — la canonada de props era lletja, però era un comptador honest de l'acoblament.

Per això el store es guanya el lloc i no se li regala. La pregunta no és quantes props hi ha. És si algun component del camí en rep una que no fa servir.