02Frontend5 min

When a global store earns its place

Global state isn't justified by convenience. It's justified when a middle component receives props it never uses, only to pass them down.

By Salva Castells

Cover image: Fabian Horst, CC BY-SA 4.0.
In this post

In an image generation SaaS I had a page with two children: a sidebar with the controls and a gallery dialog. The page rendered nothing of its own. It was this:

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

The page used none of that state. It declared it because it was the common ancestor of the two components that did. That's prop-drilling in its most honest form: state floats up to wherever it hurts least, and comes back down through a pipe of props.

The concrete signal that makes me migrate

It isn't "there are a lot of props". It's more specific: a component receives a prop, never reads it, and passes it whole to the next one.

In that board the sidebar received selectedModels and onModelsChange only to forward them to the models popover. It never looked at them. The page received galleryFilters from its own useState only to hand them to the dialog. Every time I added a new generation field I touched three files, and none of the three had any logic related to that field.

The second signal, following from the first: prop types turn into a mirror of the state. GalleryDialogProps had four entries — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — and none of them described what the dialog does. They described how it gets told.

The migration was four commits in a row in one afternoon. First a use-board-store.ts with the gallery and the attached images, and the page consuming it. Then the children reading from the store directly, and at that point the page looked like this:

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

Zero state props. The component went back to being what it claimed to be: a layout. When in the third commit I moved prompt, selectedAspectRatio, selectedModels and imageCount into the store, the sidebar lost 83 lines and gained 9.

What I don't put in the store

The temptation after a migration like that is to put everything in. Don't, and the diff itself shows why.

The models popover had four props: open, onOpenChange, selectedModels, onModelsChange. Only two went to the store. The other two stayed, but moved down:

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

The criterion is who else needs to read that value. selectedModels is read by the popover, by the sidebar to compute the estimated cost, and by the ratio selector to know which proportions are available. open is read by nobody but the popover itself. If I had lifted it into the store I'd have five xOpen booleans in global state for no reason, plus the bug on the day two of them stay open at once.

I also keep anything derivable out of the store. Computing the available proportions is the intersection of what each selected model supports. It's a function of selectedModels, so it lives where it's used and gets recomputed. An availableAspectRatios field in the store would be a copy you have to keep in sync, and the day it drifts the user sees a ratio the model won't accept.

  • In the store: whatever two different branches of the tree read.
  • Local to the component: whatever is ephemeral to an interaction — open/closed, hover, focus.
  • Nowhere: whatever can be computed from the above.

The canvas editor, where the tree is flat and there's still no store

Around the same time I was working on an office floor plan editor, Figma-like but for desks, on top of Konva. Intuition there says "this is screaming for a store": there's a canvas, a toolbar, a properties panel, and they all touch the same shapes.

It has no store. It's still useState in the root component:

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

And it works because the tree is flat. The editor has three direct children and each one receives what it actually uses. Nobody forwards anything. It passes the criterion above from the good side: there's no middle component receiving props it doesn't read.

What did hurt in that editor wasn't the state, it was the explosion of types. There were nine desk variants as distinct types: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Nine interfaces identical but for the name, nine branches in the canvas switch, nine component files. The refactor that fixed it collapsed them into two, multi-desk-facing and multi-desk-parallel, with a seats field:

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

It deleted seven files and removed 453 lines from the component that draws the plan in the booking view. No store would have fixed that, because the problem was never where the state lived but how many shapes it had.

What changes when state leaves the props

You lose something real: looking at a component's signature no longer tells you what it depends on. You have to open it. Tests can no longer inject state through props and have to set up the store. And you lose the friction that warned you were coupling too much — the pipe of props was ugly, but it was an honest coupling counter.

That's why the store earns its place instead of being handed one. The question isn't how many props there are. It's whether any component along the way receives one it doesn't use.