Como pensar a API de um componente React
Um guia prático para desenhar componentes React com nomes de props melhores, menos estados inválidos, menos APIs inchadas e uma noção mais clara de quando usar composição.
Grande parte da qualidade de uma base React não vem de hook avançado, otimização exótica ou biblioteca nova.
Vem da API dos componentes.
É ela que decide como a UI é montada, quais estados a abstração permite, quanto atrito existe no ponto de uso e quão bem aquele componente envelhece quando o sistema cresce.
Esse tema costuma parecer superficial no começo. Afinal, estamos "só escolhendo nomes de props" ou "só decidindo se um componente recebe children ou um array de configuração".
Mas, na prática, é justamente aí que muita coisa começa a dar certo ou errado.
Quando a API é mal desenhada, o custo aparece em toda parte:
- chamadas verbosas
- combinações de props difíceis de entender
- estados inválidos que o tipo não impede
- componentes inchados por flags acumuladas
- abstrações difíceis de evoluir sem quebrar o uso
Quando a API é boa, acontece o contrário. O uso fica natural, a intenção fica clara e o componente parece menor do que realmente é.
Este artigo é sobre isso: como pensar a API de componentes React de um jeito mais deliberado.
Não como um conjunto de regras absolutas, mas como um modelo mental prático para tomar decisões melhores.
Componente também é interface
Quando você cria um componente, não está só encapsulando JSX.
Você está definindo uma interface.
E, como toda interface, ela embute decisões sobre:
- o que é responsabilidade do componente
- o que é responsabilidade de quem consome
- quais variações são válidas
- quais estados deveriam ser impossíveis
Uma boa API de componente costuma fazer duas coisas ao mesmo tempo:
- deixa o caso comum simples
- dificulta estados ruins ou incoerentes
Esse ponto importa mais do que "ser flexível" no abstrato.
Na maioria das vezes, um componente "muito flexível" sem desenho claro é só um componente com prop demais.
Nome de prop deve aproveitar o contexto que já existe
Um erro muito comum é repetir no nome da prop um contexto que o próprio componente já fornece.
Exemplo:
<Dialog
isDialogOpen={isDialogOpen}
onDialogClose={handleDialogClose}
/>O nome Dialog já informa sobre o que essas props tratam. Repetir Dialog dentro de isDialogOpen e onDialogClose não acrescenta semântica real para quem está lendo o uso.
Uma API melhor seria:
<Dialog isOpen={isDialogOpen} onClose={handleDialogClose} />Repare na assimetria:
- no componente pai,
isDialogOpenpode ser um bom nome - na API do
Dialog,isOpencostuma ser melhor
Isso acontece porque os dois lados vivem em contextos diferentes.
No componente pai, talvez existam vários estados de abertura ao mesmo tempo:
const [isFiltersDialogOpen, setIsFiltersDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false)Ali, ser específico ajuda.
Já na API de Dialog, o contexto já está embutido no nome do componente. Então a prop pode ser mais curta sem perder clareza.
Esse raciocínio vale além de modais.
Veja este exemplo:
<Pattern colorVariable="--color-blue-200" />O nome colorVariable já comunica duas coisas:
- é uma cor
- vem de uma tabela de tokens/variáveis
Então talvez o valor não precise repetir tudo isso. Uma API mais limpa pode ser:
<Pattern color="blue-200" />e o componente decide internamente como transformar isso em var(--color-blue-200).
A pergunta útil aqui é simples:
o quanto desse nome realmente adiciona informação nova, e o quanto é só contexto duplicado?
Se for duplicação, a API provavelmente pode ficar menor.
Nem toda necessidade nova precisa virar prop nova
Uma das formas mais comuns de uma API piorar é crescer por reação.
Surge um caso novo, entra uma prop.
Surge outro, entra mais uma.
Logo a abstração começa a acumular flags que, isoladamente, parecem inofensivas, mas juntas viram uma matriz de comportamento difícil de raciocinar.
Exemplo:
<Dialog
isOpen={isOpen}
onClose={handleClose}
isClosable={false}
/>Depois disso, costumam aparecer outras:
closeOnEscapecloseOnOutsideClickshowCloseButtonpreventCloseOnPending
Em algum momento, a API já não está descrevendo um componente. Está descrevendo um painel de controle.
Antes de criar uma flag nova, vale perguntar se esse comportamento já pode ser derivado de algo que existe.
No caso acima, uma alternativa é tratar onClose como a própria capacidade de fechar:
<Dialog isOpen={isOpen} onClose={handleClose} />
<Dialog isOpen={isOpen} />Nesse modelo:
- se
onCloseexiste, o componente pode fechar - se
onClosenão existe, ele não deve expor mecanismos de fechamento
Isso reduz a API e elimina uma combinação estranha:
<Dialog isOpen={isOpen} onClose={handleClose} isClosable={false} />Se existe onClose, por que o componente ao mesmo tempo diz que não é fechável?
Derivação não é regra cega
Também não vale transformar isso em dogma.
Às vezes um callback existe só como observabilidade:
<Popover onClose={trackClose} />Se onClose nesse componente significa apenas "me avise quando fechar", derivar a capacidade de fechar a partir dele pode introduzir ambiguidade.
Então a heurística útil não é "nunca criar boolean".
É esta:
antes de adicionar uma prop nova, verifique se ela representa uma decisão realmente independente ou só um reflexo de outra prop já existente.
Modele a variação de um jeito que estados inválidos fiquem difíceis
Esse talvez seja o princípio mais importante do artigo.
Uma API boa não só documenta o uso correto. Ela também ajuda a impedir o uso incorreto.
Boolean é ótimo para sim ou não
Quando a natureza do problema é realmente binária, boolean funciona muito bem:
disabledloadingrequiredreadOnly
O problema começa quando boolean é usado para modelar algo que não é binário.
Exemplo:
<Button isPrimary isSecondary />Essa chamada representa um estado inválido do ponto de vista do domínio, mas a API permite que ele exista.
Quando as opções são mutuamente exclusivas, um enum normalmente modela melhor:
<Button variant="primary" />
<Button variant="secondary" />
<Button variant="ghost" />Isso melhora várias coisas ao mesmo tempo:
- deixa o contrato mais explícito
- impede combinações incoerentes
- melhora autocomplete e facilita descobrir as opções disponíveis
Em TypeScript, o contrato fica claro:
type ButtonVariant = "primary" | "secondary" | "ghost"
interface ButtonProps {
variant?: ButtonVariant
children: React.ReactNode
}E, na implementação, esse tipo de prop costuma se encaixar bem com data-*:
function Button({ variant = "primary", children }: ButtonProps) {
return (
<button data-variant={variant} className="button">
{children}
</button>
)
}.button[data-variant="primary"] {
/* ... */
}
.button[data-variant="secondary"] {
/* ... */
}O princípio é maior que variant
Esse raciocínio vale para outras partes da API também.
Exemplo:
<Tabs activeIndex={2} defaultActiveIndex={0} />Aqui o componente está recebendo, ao mesmo tempo, a versão controlada e a não controlada da mesma decisão.
Uma API melhor costuma separar esses contratos com mais clareza:
<Tabs value={value} onValueChange={setValue} />
<Tabs defaultValue="account" />O mesmo vale para dualidades comuns em React:
openvsdefaultOpenvaluevsdefaultValuecheckedvsdefaultChecked
Se o componente aceita os dois modos, a API precisa deixar claro que são dois contratos diferentes, não duas props aleatórias convivendo.
No fundo, o objetivo é sempre o mesmo: fazer com que estados inválidos ou ambíguos fiquem difíceis de representar.
Quando a estrutura varia, composição costuma escalar melhor do que prop demais
Muita abstração começa simples e vai inchando conforme tenta absorver mais casos por props.
Você começa com isto:
<Dropdown items={items} />Depois alguém quer um separador.
Depois alguém quer um item com ícone.
Depois alguém quer customizar o footer, o header, a seta, a renderização do label, a classe de uma parte interna específica.
Se tudo isso precisa passar pela superfície do componente pai, a API começa a inchar:
<Dropdown
items={items}
renderItemIcon={renderItemIcon}
renderFooter={renderFooter}
showArrow
separatorClassName="..."
itemClassName="..."
contentClassName="..."
/>Esse é o ponto em que composição costuma fazer mais sentido do que continuar empilhando props.
Em vez de o componente receber dados e dezenas de opções para decidir internamente como montar tudo, você deixa a estrutura emergir do próprio JSX.
Exemplo:
<Slides>
<Slide title="Prop naming">
<p>...</p>
</Slide>
<Slide title="Composição">
<p>...</p>
</Slide>
</Slides>Compare isso com uma API orientada a dados:
<Slides
data={[
{
title: "Prop naming",
content: <p>...</p>
},
{
title: "Composição",
content: <p>...</p>
}
]}
/>A segunda versão até pode parecer conveniente à primeira vista, mas perde algumas coisas importantes:
- a leitura estrutural do JSX
- a liberdade de passar props específicas para cada item
- a ergonomia de tratar cada parte como UI, não como configuração serializada
Quando você escolhe composição, está dizendo que a estrutura faz parte do contrato.
Isso costuma escalar melhor quando a variação real do problema é estrutural, e não apenas visual.
Compound components
Uma forma comum de aplicar isso em design systems é o padrão de compound components.
Em vez de um menu expor tudo por props:
<Menu hasArrow label="Ações" items={items} />ele expõe partes:
<Menu.Root>
<Menu.Trigger>Ações</Menu.Trigger>
<Menu.Content>
<Menu.Item>Editar</Menu.Item>
<Menu.Item>Duplicar</Menu.Item>
<Menu.Separator />
<Menu.Item>Arquivar</Menu.Item>
</Menu.Content>
</Menu.Root>Isso aumenta um pouco a cerimônia no ponto de uso, mas compra algo importante: controle estrutural sem inflar a API do componente pai.
Se você não quer uma seta, basta não renderizar essa parte.
Se quer estilizar o separador, estiliza aquela peça.
Se precisa inserir um bloco intermediário entre itens, isso vira parte da própria árvore, não uma exceção espremida em uma prop.
Composição também tem custo
Aqui vale nuance.
Composição não é automaticamente a melhor resposta para todo componente.
Para componentes primitivos e componentes base, ela costuma ser excelente.
Para componentes de produto, mais específicos, uma API mais fechada muitas vezes é melhor.
Exemplo:
<DeleteAccountDialog />Esse componente provavelmente não precisa expor Root, Trigger, Content, Footer, Description e uma mini linguagem de montagem se o caso de uso dele for único e conhecido.
Uma boa regra prática é:
- componentes base: mais composição
- componentes de produto: mais opinião e menos abertura
O erro comum não é compor demais nem fechar demais de forma abstrata.
É errar o nível da abstração.
Se o consumidor precisa customizar partes internas o tempo todo, talvez a API esteja fechada demais.
Se todo componente precisa de uma DSL inteira para montar um caso óbvio, talvez a API esteja aberta demais.
Nem todo detalhe da interface pertence ao ciclo declarativo do React
Falar de API de componente também exige falar de fronteira.
Nem todo comportamento visual precisa passar por state, re-render, reconciliação e commit.
Isso aparece com força em interações de alta frequência:
- mouse move
- pan
- drag
- animações contínuas
- qualquer atualização visual a cada frame
Se você chama setState para cada evento, força o React a refazer trabalho demais para um estado que talvez nem precise participar do render.
Nesses casos, muitas vezes o caminho certo é usar a plataforma diretamente:
function CursorFollower() {
const ref = useRef<HTMLDivElement | null>(null)
function handleMouseMove(event: React.MouseEvent<HTMLDivElement>) {
if (!ref.current) return
ref.current.style.translate = `${event.clientX}px ${event.clientY}px`
}
return (
<div onMouseMove={handleMouseMove}>
<div ref={ref} />
</div>
)
}O ponto não é "ignorar React".
O ponto é reconhecer que React é muito bom para modelar estado que participa do render.
Mas, para estado puramente imperativo, transitório e de altíssima frequência, passar tudo pelo ciclo declarativo pode ser custo sem benefício.
Se outras partes da interface também dependem desse valor, ele provavelmente ainda pertence ao estado React.
Isso também é parte de desenhar bons componentes: saber o que realmente pertence ao contrato declarativo da API e o que deve continuar como detalhe interno de implementação.
Fechando
Desenhar a API de um componente é desenhar as fronteiras do problema.
É decidir:
- o que precisa ser configurável
- o que pode ser derivado
- o que deveria ser impossível
- quando a estrutura precisa ser aberta
- e quando vale ser mais opinativo
Se eu tivesse que resumir tudo em poucas heurísticas, seriam estas:
- remova contexto duplicado dos nomes de props
- antes de criar uma prop nova, veja se ela representa uma decisão independente
- use boolean para decisões binárias e enum para variações mutuamente exclusivas
- tente modelar a API de um jeito que estados inválidos fiquem difíceis
- quando a estrutura realmente variar, prefira composição a empilhar props
- quando o caso de uso for específico, prefira uma API mais fechada
- nem todo detalhe visual precisa passar pelo estado declarativo do React
No fim, uma boa API de componente não é a mais genérica, nem a mais "inteligente".
É a que faz o uso correto parecer o uso natural.