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.
