02Frontend5 min

Quando um store global conquista seu lugar

O estado global não se justifica por comodidade. Justifica-se quando um componente intermediário recebe props que não usa, só para passá-las adiante.

Por Salva Castells

Imagem de capa: Fabian Horst, CC BY-SA 4.0.
Neste post

Num SaaS de geração de imagens eu tinha uma página com dois filhos: uma barra lateral com os controles e um diálogo de galeria. A página não pintava nada próprio. Era isto:

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

Nada desse estado era usado pela página. Ela o declarava porque era o ancestral comum dos dois que realmente usavam. Isso é prop-drilling na sua forma mais honesta: o estado sobe até onde incomoda menos, e desce por um encanamento de props.

O sinal concreto que me faz migrar

Não é "há muitas props". É mais específico: um componente recebe uma prop, não a lê, e a passa inteira ao próximo.

Naquele board a barra lateral recebia selectedModels e onModelsChange só para reenviá-los ao popover de modelos. Não olhava para eles. A página recebia galleryFilters do seu próprio useState só para entregá-los ao diálogo. Cada vez que eu adicionava um campo de geração novo, mexia em três arquivos e nenhum dos três tinha lógica relacionada àquele campo.

O segundo sinal, derivado do primeiro: os tipos de props viram um espelho do estado. GalleryDialogProps tinha quatro entradas — galleryOpen, galleryFilters, onGalleryClose, onGalleryFiltersChange — e nenhuma descrevia o que o diálogo faz. Descreviam como falam com ele.

A migração foram quatro commits seguidos numa tarde. Primeiro um use-board-store.ts com a galeria e as imagens anexadas, e a página consumindo-o. Depois os filhos lendo do store diretamente, e aí a página ficou assim:

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

Zero props de estado. O componente voltou a ser o que dizia ser: um layout. Quando no terceiro commit coloquei prompt, selectedAspectRatio, selectedModels e imageCount no store, a barra lateral perdeu 83 linhas e ganhou 9.

O que eu não coloco no store

A tentação depois de uma migração assim é colocar tudo. Não se deve, e o próprio diff ensina isso.

O popover de modelos tinha quatro props: open, onOpenChange, selectedModels, onModelsChange. Só duas foram para o store. As outras duas ficaram, mas desceram de lugar:

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

O critério é quem mais precisa ler aquele valor. selectedModels é lido pelo popover, é lido pela barra lateral para calcular o custo estimado e é lido pelo seletor de proporção para saber quais proporções estão disponíveis. open não é lido por ninguém além do próprio popover. Se eu o tivesse subido para o store teria cinco booleanos xOpen no estado global sem razão alguma, e o bug do dia em que dois ficam abertos ao mesmo tempo.

Também não coloco no store nada derivável. O cálculo de proporções disponíveis é a interseção das que cada modelo selecionado suporta. É uma função de selectedModels, então vive onde é usada e é recalculado. Um campo availableAspectRatios no store seria uma cópia que precisa ser mantida sincronizada, e no dia em que dessincronizar o usuário verá uma proporção que o modelo não aceita.

  • Para o store: o que é lido por dois ramos distintos da árvore.
  • Local ao componente: o efêmero de uma interação — aberto/fechado, hover, foco.
  • Em lugar nenhum: o que pode ser calculado a partir do anterior.

O editor de canvas, onde a árvore é plana e mesmo assim não há store

Pela mesma época eu estava com um editor de plantas de escritório, tipo Figma mas para mesas, sobre Konva. Ali a intuição diz "isso pede um store aos gritos": há uma tela, uma barra de ferramentas, um painel de propriedades e todos mexem nas mesmas figuras.

Não tem store. Continua sendo useState no componente raiz:

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

E funciona porque a árvore é plana. O editor tem três filhos diretos e cada um recebe o que de fato usa. Ninguém reenvia nada. Cumpre o critério de cima pelo lado bom: não há nenhum componente intermediário recebendo props que não lê.

O que doeu naquele editor não foi o estado, foi a explosão de tipos. Havia nove variantes de mesa como tipos distintos: dual-desk-facing, quad-desk-parallel, hex-desk-facing, octo-desk-parallel. Nove interfaces idênticas exceto pelo nome, nove ramos no switch da tela, nove arquivos de componente. O refactor que consertou isso colapsou tudo em dois, multi-desk-facing e multi-desk-parallel, com um campo seats:

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

Apagou sete arquivos e tirou 453 linhas do componente que pinta a planta na visão de reservas. Nenhum store teria consertado isso, porque o problema nunca foi onde o estado vivia, e sim quantas formas ele tinha.

O que muda quando o estado sai das props

Perde-se algo real: olhando a assinatura de um componente você já não sabe do que ele depende. É preciso abri-lo. Os testes deixam de poder injetar estado por props e precisam preparar o store. E some o atrito que avisava que você estava acoplando demais — o encanamento de props era feio, mas era um contador honesto do acoplamento.

Por isso o store conquista o lugar e não o ganha de presente. A pergunta não é quantas props existem. É se algum componente do caminho recebe uma que não usa.