DEV Community

Gabriel Teixeira da Silva
Gabriel Teixeira da Silva

Posted on • Edited on

Criando Componentes Flexíveis com Compound Pattern e Composition

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... 🤯
/>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

4. Finalizando a Composição

export const Accordion = Object.assign(AccordionRoot, {
  Item: AccordionItem,
  Trigger: AccordionTrigger,
  Content: AccordionContent,
});
Enter fullscreen mode Exit fullscreen mode

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)