Criando Componentes Flexíveis com Compound Pattern e Composition
Source: Dev.to
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)
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
<div>
<h2>O que é React?</h2>
<p>Uma biblioteca JavaScript para criar interfaces...</p>
</div>
<div>
<h3>Acessibilidade</h3>
<button>Saiba mais</button>
</div>
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";
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 ");
}
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 }}>
{children}
</AccordionContext.Provider>
);
};
3. Os Sub‑Componentes Inteligentes
O Item (Gerenciador de IDs)
// Contexto para passar IDs e Valor para os filhos (Trigger e Content)
const ItemContext = createContext<any>(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 }}>
{children}
</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}
aria-expanded={isOpen}
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}
{isOpen ? "➖" : "➕"}
</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}
className="p-4"
>
{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 as tags <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>?”
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
<details>exige interceptar eventos e manipular o atributoopenmanualmente, 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 deheight: auto). Com React podemos controlar a renderização e aplicar animações CSS ou bibliotecas de animação de forma previsível.