02Frontend6 min

La prop que nadie lee

Mi señal para sacar estado a un store es muy concreta: un componente que recibe una prop, no la mira y se la pasa al siguiente. Y un editor donde todo pedía store a gritos y no le hizo falta.

Por Salva Castells

Imagen de portada: Fabian Horst, CC BY-SA 4.0.
En este post

Esta era la página principal de un SaaS de generación de imágenes. Una barra lateral con los controles, un diálogo de galería, y poco más:

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

La página no usaba nada de ese estado. Lo declaraba porque era el antepasado común de los dos componentes que sí lo usaban, y ahí se quedaba, como un intermediario que cobra comisión sin hacer nada. Es el prop drilling de toda la vida, en su versión más inocente: el estado sube hasta donde molesta menos y luego baja por una tubería de props.

Lo que me hizo moverlo no fue que hubiera muchas props. Hay componentes con doce props que están perfectamente, porque las usan todas. Fue algo más pequeño y más fácil de detectar.

Recibir, no mirar, reenviar

La barra lateral recibía selectedModels y onModelsChange y no hacía nada con ellos más que pasárselos al popover de modelos. La página tenía galleryFilters en su propio useState solo para dárselo al diálogo. Cada vez que añadía un parámetro de generación nuevo, tocaba tres ficheros, y en ninguno de los tres había lógica relacionada con ese parámetro.

Esa es la señal que miro: un componente recibe una prop, no la lee y la pasa entera al siguiente. Cuando la ves una vez es un detalle. Cuando la ves en dos niveles seguidos, ese estado ya no pertenece a ese árbol.

Hay una segunda pista que sale de la primera, y es mirar los tipos. GalleryDialogProps tenía cuatro entradas: galleryOpen, galleryFilters, onGalleryClose y onGalleryFiltersChange. Léelas otra vez y dime qué hace el diálogo. No se puede saber. Las props describían cómo le llegaba la información, no qué hacía el componente con ella. Cuando la interfaz de un componente se ha convertido en un espejo del estado de su padre, algo huele.

Una tarde, cuatro commits

La migración fue rápida. Primero un use-board-store.ts con la galería y las imágenes adjuntas, con la página todavía como único consumidor, para comprobar que nada se rompía. Después, los hijos leyendo del store directamente. Ahí la página se quedó así:

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

Ninguna prop de estado. La página volvió a ser un layout, que es lo que había sido siempre en teoría. En el tercer commit metí prompt, selectedAspectRatio, selectedModels e imageCount en el store, y la barra lateral perdió 83 líneas y ganó 9.

Me gusta hacerlo en pasos así de pequeños porque si algo se rompe sabes exactamente qué movimiento lo ha roto. Moverlo todo de golpe es tentador cuando ves claro el destino, y es cuando más fácil es colar un bug de re-render que tardas una semana en notar.

Lo que se quedó fuera, y por qué

Después de una migración así te entran ganas de meterlo todo en el store. Aquí el diff me frenó.

El popover de modelos tenía cuatro props: open, onOpenChange, selectedModels y onModelsChange. Solo las dos últimas se fueron al store. Las otras dos no desaparecieron, bajaron un nivel y se metieron dentro del propio popover:

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

La pregunta que me hago es quién necesita leer el valor. selectedModels lo lee el popover, la barra lateral para calcular el coste estimado, y el selector de ratio para saber qué proporciones hay disponibles. Tres sitios en ramas distintas: store. open solo lo lee el propio popover. Si lo hubiera subido, en un mes tendría cinco booleanos algoOpen en el estado global sin motivo, y algún día el bug de dos popovers abiertos a la vez porque nadie cierra el anterior.

Un inciso técnico, por si tu store es de Zustand: desestructurar el resultado de useBoardStore() sin selector suscribe el componente al store entero, y se re-renderiza con cualquier cambio, aunque no toque lo que usa. Con un selector no pasa:

const selectedModels = useBoardStore((s) => s.selectedModels);
const setSelectedModels = useBoardStore((s) => s.setSelectedModels);

En un popover da igual. En una barra lateral que se re-renderiza cada vez que escribes una letra en el prompt, empieza a no dar igual.

Tampoco meto en el store nada que se pueda calcular. Las proporciones disponibles son la intersección de las que soporta cada modelo seleccionado, una función de selectedModels. Así que se calculan donde se usan. Si guardara un availableAspectRatios en el store sería una copia que mantener sincronizada, y el día que se desincronizara el usuario vería en el selector un ratio que el modelo no acepta.

Resumiendo cómo lo reparto yo, sin pretender que sea ley: al store va lo que leen dos ramas distintas del árbol; lo efímero de una interacción (abierto, cerrado, hover, foco) se queda en el componente; y lo derivable no vive en ningún sitio, se calcula.

El editor que pedía store a gritos

Por la misma época estaba haciendo un editor de planos de oficina sobre Konva, una especie de Figma para colocar escritorios. Si hay un sitio donde la intuición dice "aquí va un store", es ese: un lienzo, una barra de herramientas y un panel de propiedades, todos tocando las mismas figuras.

No lo lleva. Sigue siendo useState en el componente raíz:

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

Y va bien porque el árbol es plano. El editor tiene tres hijos directos y cada uno recibe lo que usa. Nadie reenvía nada. Pasa la prueba de la prop sin leer sin ningún esfuerzo, así que no había nada que arreglar por ese lado. Sé que mucha gente habría metido un store aquí por defecto, "porque es un editor", y no digo que esté mal. Digo que a mí no me habría solucionado nada.

Lo que sí me estaba haciendo daño en ese editor era otra cosa. Tenía nueve variantes de escritorio como tipos separados: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel y compañía. Nueve interfaces idénticas salvo el nombre, nueve ramas en el switch del lienzo, nueve ficheros de componente. El refactor las dejó en dos, multi-desk-facing y multi-desk-parallel, con un campo seats:

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

Siete ficheros menos y 453 líneas fuera del componente que pinta el plano en la vista de reservas. Ahí el problema nunca había sido dónde vivía el estado, sino cuántas formas distintas tenía, y ningún store habría tocado eso. Lo menciono porque creo que a veces echamos mano del estado global cuando lo que está mal es el modelo de datos, y el store solo lo esconde un poco mejor.

Lo que echo de menos de la tubería

Mover estado a un store tiene un precio y no quiero pasarlo por alto. Mirando la firma de Sidebar ya no sé de qué depende, tengo que abrirla. Los tests ya no pueden pasarle estado por props, tienen que preparar el store antes. Y perdí algo que no sabía que valoraba: la tubería de props era fea, pero cada prop reenviada me recordaba que estaba acoplando dos cosas lejanas. Con el store, acoplar sale gratis, y lo que sale gratis se acaba usando de más.

Por eso no lo pongo por defecto. Espero a ver la prop que nadie lee, y hasta que no aparece, useState y props. Alguna vez esperaré demasiado, supongo, pero prefiero ese error al contrario.