portfólio
← voltar

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.

Matheus Pergoli··8 min de leitura

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:

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:

Uma boa API de componente costuma fazer duas coisas ao mesmo tempo:

  1. deixa o caso comum simples
  2. 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:

TSX
<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:

TSX
<Dialog isOpen={isDialogOpen} onClose={handleDialogClose} />

Repare na assimetria:

Isso acontece porque os dois lados vivem em contextos diferentes.

No componente pai, talvez existam vários estados de abertura ao mesmo tempo:

TSX
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:

TSX
<Pattern colorVariable="--color-blue-200" />

O nome colorVariable já comunica duas coisas:

Então talvez o valor não precise repetir tudo isso. Uma API mais limpa pode ser:

TSX
<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:

TSX
<Dialog
  isOpen={isOpen}
  onClose={handleClose}
  isClosable={false}
/>

Depois disso, costumam aparecer outras:

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:

TSX
<Dialog isOpen={isOpen} onClose={handleClose} />
 
<Dialog isOpen={isOpen} />

Nesse modelo:

Isso reduz a API e elimina uma combinação estranha:

TSX
<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:

TSX
<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:

O problema começa quando boolean é usado para modelar algo que não é binário.

Exemplo:

TSX
<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:

TSX
<Button variant="primary" />
<Button variant="secondary" />
<Button variant="ghost" />

Isso melhora várias coisas ao mesmo tempo:

Em TypeScript, o contrato fica claro:

TSX
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-*:

TSX
function Button({ variant = "primary", children }: ButtonProps) {
  return (
    <button data-variant={variant} className="button">
      {children}
    </button>
  )
}
CSS
.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:

TSX
<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:

TSX
<Tabs value={value} onValueChange={setValue} />
 
<Tabs defaultValue="account" />

O mesmo vale para dualidades comuns em React:

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:

TSX
<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:

TSX
<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:

TSX
<Slides>
  <Slide title="Prop naming">
    <p>...</p>
  </Slide>
  <Slide title="Composição">
    <p>...</p>
  </Slide>
</Slides>

Compare isso com uma API orientada a dados:

TSX
<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:

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:

TSX
<Menu hasArrow label="Ações" items={items} />

ele expõe partes:

TSX
<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:

TSX
<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 é:

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:

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:

TSX
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:

Se eu tivesse que resumir tudo em poucas heurísticas, seriam estas:

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.

← voltar para o blog