Se você trabalha com React há algum tempo, provavelmente já se deparou com o dilema do "Componente Monolítico". Aquele componente que começa inofensivo, mas à medida que o projeto cresce, ele começa a aceitar dezenas de props para tratar cada pequena variação de layout e lógica.
Hoje, vamos falar sobre como evitar isso usando dois padrões poderosos: Compound Components e Composition Pattern, sem deixar de lado a Acessibilidade.
Vamos usar um exemplo clássico de UI: um Accordion (lista expansível), mas a lógica se aplica a Modais, Tabs, Menus e muito mais.
O Problema: A Abordagem Rígida
Imagine que você precisa criar um Accordion. A abordagem iniciante geralmente centraliza tudo na configuração:
// ❌ O jeito rígido (Componente Monolítico)
<Accordion
items={[
{ id: 1, title: "O que é React?", content: "Uma lib JS..." },
{ id: 2, title: "Acessibilidade", content: "É essencial..." }
]}
allowMultiple={true}
iconPosition="right"
titleColor="blue"
// E se eu quiser colocar um ícone SVG no título?
// E se eu quiser um botão dentro do conteúdo?
// Mais props... 🤯
/>
O problema aqui é que a configuração (data) está misturada com a UI. Se o designer decidir que o título do segundo item precisa ser vermelho, você terá que "sujar" seu código com mais props condicionais.
A Solução: Compound Components + Acessibilidade
O padrão Compound Component permite que componentes trabalhem juntos, compartilhando estado implicitamente, enquanto a Composition deixa o desenvolvedor decidir onde renderizar cada parte.
Além disso, ao criarmos nossos próprios componentes, temos a responsabilidade de torná-los acessíveis. Um Accordion sem atributos ARIA (aria-expanded, aria-controls) é invisível para usuários que dependem de leitores de tela.
Queremos chegar neste resultado (DX - Developer Experience):
// ✅ O jeito flexível e acessível
<Accordion>
<Accordion.Item value="item-1">
<Accordion.Trigger>O que é React?</Accordion.Trigger>
<Accordion.Content>
Uma biblioteca JavaScript para criar interfaces...
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>
<span>Acessibilidade</span>
<IconeAcessibilidade />
</Accordion.Trigger>
<Accordion.Content>
<button>Saiba mais</button>
</Accordion.Content>
</Accordion.Item>
</Accordion>
Mão na Massa: Implementando o Padrão
Vamos construir isso usando React Context, Hooks e useId para garantir a acessibilidade.
1. Criando o Contexto Principal
Primeiro, o estado global do Accordion.
import React, { createContext, useContext, useState, ReactNode, useId } from "react";
// Definindo o formato do nosso contexto
type AccordionContextType = {
openItem: string | null;
toggleItem: (value: string) => void;
};
const AccordionContext = createContext<AccordionContextType | undefined>(undefined);
const useAccordion = () => {
const context = useContext(AccordionContext);
if (!context) {
throw new Error("Os componentes do Accordion devem estar dentro de <Accordion />");
}
return context;
};
2. O Componente Pai (Root)
interface AccordionProps {
children: ReactNode;
}
const AccordionRoot = ({ children }: AccordionProps) => {
const [openItem, setOpenItem] = useState<string | null>(null);
const toggleItem = (value: string) => {
setOpenItem((prev) => (prev === value ? null : value));
};
return (
<AccordionContext.Provider value={{ openItem, toggleItem }}>
<div className="accordion-root border rounded-md max-w-lg mx-auto">
{children}
</div>
</AccordionContext.Provider>
);
};
3. Os Sub-Componentes Inteligentes
Aqui está o segredo da acessibilidade. Vamos gerar IDs únicos para vincular o botão (Trigger) ao conteúdo (Content), para que o leitor de tela saiba o que aquele botão controla.
O Item (Gerenciador de IDs):
// Contexto para passar IDs e Valor para os filhos (Trigger e Content)
const ItemContext = createContext<{
value: string;
triggerId: string;
contentId: string;
} | undefined>(undefined);
const AccordionItem = ({ value, children }: { value: string; children: ReactNode }) => {
const id = useId(); // Gera um ID único para este item
const triggerId = `accordion-trigger-${id}`;
const contentId = `accordion-content-${id}`;
return (
<ItemContext.Provider value={{ value, triggerId, contentId }}>
<div className="accordion-item border-b last:border-none p-2">
{children}
</div>
</ItemContext.Provider>
);
};
O Gatilho (Trigger):
const AccordionTrigger = ({ children }: { children: ReactNode }) => {
const { openItem, toggleItem } = useAccordion();
const itemContext = useContext(ItemContext);
if (!itemContext) throw new Error("Trigger deve estar dentro de um Item");
const { value, triggerId, contentId } = itemContext;
const isOpen = openItem === value;
return (
<button
id={triggerId}
aria-controls={contentId} // Diz ao leitor de tela o que este botão controla
aria-expanded={isOpen} // Diz se está aberto ou fechado
onClick={() => toggleItem(value)}
className="flex justify-between w-full p-4 font-medium text-left hover:bg-gray-50 transition focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{children}
<span aria-hidden="true">{isOpen ? "➖" : "➕"}</span>
</button>
);
};
O Conteúdo (Content):
const AccordionContent = ({ children }: { children: ReactNode }) => {
const { openItem } = useAccordion();
const itemContext = useContext(ItemContext);
if (!itemContext) throw new Error("Content deve estar dentro de um Item");
const { value, triggerId, contentId } = itemContext;
const isOpen = openItem === value;
if (!isOpen) return null;
return (
<div
id={contentId}
role="region"
aria-labelledby={triggerId} // Vincula de volta ao botão
className="p-4 text-gray-600 bg-white animate-fade-in"
>
{children}
</div>
);
};
4. Finalizando a Composição
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
Por que a Acessibilidade Importa?
Quando criamos componentes customizados ("na unha"), perdemos a semântica nativa do HTML (como a tag <details> e <summary>).
Ao adicionar aria-expanded e aria-controls, garantimos que usuários que navegam via teclado ou leitores de tela tenham a mesma experiência que usuários visuais. Um componente bonito que não pode ser usado por todos é um componente incompleto.
Mas e o <details> e <summary>?
Você deve estar se perguntando: "Por que escrever tanto código se o HTML5 já tem as tags <details> e <summary>?"
Essa é uma ótima pergunta. O uso de tags nativas é sempre encorajado, pois garante acessibilidade e SEO "out-of-the-box". No entanto, criar um Accordion controlado via React (como fizemos acima) é necessário quando:
Exclusividade de Abertura: Queremos que, ao abrir um item, os outros se fechem automaticamente. Fazer isso com exige interceptar eventos e manipular o atributo open manualmente, o que nos traz de volta ao gerenciamento de estado.
Animações Fluidas: Animar o fechamento de um elemento nativo <details> ainda é complexo no CSS puro (devido à transição de height: auto). Com componentes React, temos controle total sobre o ciclo de vida da animação.
Design System Rigoroso: Quando precisamos de total liberdade visual sem lutar contra os estilos padrão do User Agent (como o triângulo padrão do <summary>).
Se o seu caso de uso é simples e não exige que um item feche o outro, vá de HTML nativo! Mas para construir componentes robustos de UI libraries, o padrão Compound oferece o controle que precisamos.
Conclusão
Padrões de projeto como o Compound Components são o segredo para bibliotecas robustas como Radix UI e Chakra UI. Eles oferecem o equilíbrio perfeito entre lógica encapsulada, liberdade de estilização e acessibilidade.
Da próxima vez que for criar um componente reutilizável, lembre-se: separe a lógica da UI e nunca se esqueça dos usuários que não usam mouse.
Top comments (0)