Alternar tema

Padrões de composição no shadcn/ui: boas práticas para integrar vários componentes

Easton editorial illustration: performance tuning console

Abri o código de uma página de gerenciamento de usuários.

Uma DataTable exibia a lista de usuários. Cada linha tinha um menu de ações DropdownMenu e, ao clicar em “Editar”, abria-se um Dialog com um Form dentro. Parece uma funcionalidade bem simples, certo?

Mas o código estava assim: o estado passava de um lado para outro, o prop drilling chegava à quinta camada, o estado open do Dialog ficava no componente pai, os dados do Form no componente filho e, depois do envio, ainda era preciso repassar um callback ao pai para atualizar a DataTable…

Na época, cheguei a desconfiar do shadcn/ui. “Os componentes isolados são realmente agradáveis de usar, então por que tudo vira uma bagunça quando eu os combino?”

Depois de consultar a documentação de design do shadcn/ui, percebi que o problema não estava na biblioteca de componentes, mas na minha falta de familiaridade com padrões de composição. A ideia central do shadcn/ui é “prefira composição a herança”, e cada componente oferece uma interface consistente e previsível. Sem entender a filosofia por trás dessa interface, porém, a composição vira um emaranhado.

Hoje quero falar sobre os erros que cometi e as boas práticas de composição que aprendi depois.


Primeiro, entenda a filosofia de design do shadcn/ui

Antes de falar sobre combinações específicas, precisamos entender a lógica de design do shadcn/ui. Caso contrário, você vai se perguntar por que outras pessoas conseguem combinar componentes de forma simples e organizada, enquanto o seu código parece um prato de espaguete.

A maior diferença entre o shadcn/ui e as bibliotecas de UI tradicionais é que ele não é um pacote npm. Você não verá uma dependência chamada @shadcn/ui no package.json. O código de todos os componentes é copiado diretamente para o seu projeto.

Parece um pouco rudimentar, não é? Mas essa é justamente a filosofia de design:

Open Code: o código dos componentes é totalmente aberto, e você pode alterá-lo sem se preocupar com conflitos de versão. Se não gostar de determinado estilo do componente Button, por exemplo, pode mudar o código-fonte diretamente, sem esperar uma nova versão oficial.

Composition: todos os componentes usam uma interface de composição consistente. O que isso quer dizer? Que a estrutura de cada componente é previsível. Um Card, por exemplo, sempre segue uma estrutura aninhada como <Card>&lt;CardHeader>&lt;CardTitle>&lt;CardContent>, enquanto um Dialog segue <Dialog>&lt;DialogContent>&lt;DialogHeader>&lt;DialogTitle>.

A vantagem dessa interface consistente é que, ao combinar vários componentes, você sabe quais partes devem ser aninhadas e quais devem ficar lado a lado. Assim, não surgem contradições como “este componente precisa ficar dentro daquele, mas aquele exige ficar do lado de fora”.


Composição básica: Dialog + Form

O cenário mais comum de composição é colocar um formulário dentro de uma janela modal.

O usuário clica no botão “Editar”, um Dialog é aberto com um Form dentro e, depois que o formulário é preenchido e enviado, o Dialog se fecha. Parece simples, mas cometi um erro quando implementei isso pela primeira vez: misturei os estados do Dialog e do Form.

Exemplo incorreto

// ❌ Esta é a versão problemática que implementei
function EditUserDialog() {
  const [open, setOpen] = useState(false)
  const [formData, setFormData] = useState&#123;&#123;&#125;&#125;

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button onClick={() => fetchUserData()}>Editar</Button>
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={(e) => {
          e.preventDefault()
          submitForm(formData)
          setOpen(false)
        }}>
          <Input
            value={formData.username}
            onChange={(e) => setFormData(&#123;...formData, username: e.target.value&#125;)}
          />
          <Button type="submit">Salvar</Button>
        </form>
      </DialogContent>
    </Dialog>
  )
}

Qual é o problema? O estado open do Dialog e o estado dos dados do Form estão misturados no mesmo componente. Além disso, gerenciei o estado do formulário manualmente, sem React Hook Form, o que deixou a validação e a exibição de erros bastante confusas.

A abordagem correta

O componente Form do shadcn/ui é baseado em React Hook Form + Zod. Ao usar essa combinação, o código fica muito mais organizado:

// ✅ Forma correta de composição
import &#123; useForm &#125; from "react-hook-form"
import &#123; zodResolver &#125; from "@hookform/resolvers/zod"
import * as z from "zod"

// 1. Primeiro, defina o Schema (fora do componente)
const userSchema = z.object({
  username: z.string().min(3, "O nome de usuário deve ter pelo menos 3 caracteres"),
  email: z.string().email("Formato de e-mail inválido")
})

function EditUserDialog(&#123; user, onSubmit &#125;) {
  const [open, setOpen] = useState(false)
  const form = useForm(&#123;
    resolver: zodResolver(userSchema),
    defaultValues: user // Passe os dados do usuário diretamente
  &#125;)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">Editar</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Editar informações do usuário</DialogTitle>
        </DialogHeader>

        {/* Use diretamente o componente Form do shadcn */}
        <Form &#123;...form&#125;>
          <form onSubmit={form.handleSubmit((data) => &#123;
            onSubmit(data)      // Enviar os dados
            setOpen(false)      // Fechar o Dialog
          &#125;)}>
            <FormField
              name="username"
              render=&#123;(&#123; field &#125;) => (
                <FormItem>
                  <FormLabel>Nome de usuário</FormLabel>
                  <FormControl>&lt;Input &#123;...field&#125; /></FormControl>
                  <FormMessage /> {/* Exibe os erros automaticamente */}
                </FormItem>
              )&#125;
            />
            <Button type="submit">Salvar</Button>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}

Pontos principais:

  1. O Dialog é o contêiner e o Form é o conteúdo: o Dialog cuida apenas de “abrir/fechar”; o Form cuida de “dados/validação/envio”. As responsabilidades ficam separadas.
  2. Use React Hook Form + Zod no Form: não gerencie o estado do formulário manualmente. form.handleSubmit cuida automaticamente da validação e do envio.
  3. FormMessage exibe os erros automaticamente: não é preciso escrever a lógica de erros; as falhas de validação do Zod são mostradas automaticamente.

Com isso, os estados do Dialog e do Form ficam claros: open pertence ao Dialog no componente pai, enquanto os dados do Form ficam dentro do componente Form, gerenciados pelo React Hook Form.


DataTable + DropdownMenu: ações nas linhas da tabela

Outro cenário comum é ter um menu de ações em cada linha da tabela e abrir um Dialog ao clicar em “Editar”.

O meu problema foi não saber como repassar os dados da linha ao Dialog. Na definição de columns da DataTable, você tem acesso a row.original, que contém os dados da linha atual. Mas, se o Dialog fica fora da DataTable, como enviar esses dados?

Exemplo incorreto

// ❌ Minha primeira abordagem: aninhar o Dialog dentro de cell
const columns = [
  &#123;
    id: "actions",
    cell: &#123; row &#125; => (
      <Dialog>
        <DialogTrigger asChild>
          <Button>Editar</Button>
        </DialogTrigger>
        <DialogContent>
          {/* Problema: cada renderização de cell cria uma instância de Dialog */}
          <EditForm user={row.original} />
        </DialogContent>
      </Dialog>
    )
  &#125;
]

O problema dessa abordagem é que cada linha cria uma instância de Dialog. Se houver cem linhas, serão cem Dialogs, com péssimo desempenho. Além disso, fica muito difícil gerenciar o estado do Dialog de forma centralizada.

A abordagem correta

Use um único Dialog global e gerencie o estado com um Hook:

// 1. Primeiro, defina um Hook para gerenciar o estado do Dialog
const useEditDialog = () => &#123;
  const [open, setOpen] = useState(false)
  const [editingUser, setEditingUser] = useState(null)

  const openEdit = (user) => &#123;
    setEditingUser(user)
    setOpen(true)
  &#125;

  const closeEdit = () => &#123;
    setOpen(false)
    setEditingUser(null)
  &#125;

  return &#123; open, editingUser, openEdit, closeEdit &#125;
&#125;

// 2. Coloque apenas o botão de acionamento na definição das colunas da DataTable
function UserDataTable(&#123; users &#125;) &#123;
  const &#123; open, editingUser, openEdit, closeEdit &#125; = useEditDialog()

  const columns = [
    &#123;
      id: "actions",
      cell: &#123; row &#125; => (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon">
              <MoreHorizontal />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem onClick={() => openEdit(row.original)}>
              Editar
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
              Excluir
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    &#125;
  ]

  return (
    <>
      <DataTable columns={columns} data={users} />
      {/* Um único Dialog global */}
      <Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
        <DialogContent>
          <EditUserForm
            user={editingUser}
            onSubmit={(data) => &#123;
              updateUser(data)
              closeEdit()
              refreshTable() // Atualizar os dados da tabela
            &#125;}
          />
        </DialogContent>
      </Dialog>
    </>
  )
&#125;

Pontos principais:

  1. Dialog global: coloque um Dialog fora da DataTable, em vez de criar um para cada linha.
  2. O Hook gerencia o estado: openEdit abre o Dialog e recebe os dados; closeEdit fecha o Dialog e limpa os dados.
  3. Acionamento pelo DropdownMenu: dentro de cell fica apenas o botão de acionamento, que chama openEdit(row.original) no onClick.

A estrutura fica clara: a DataTable cuida de “exibir dados”, o DropdownMenu de “acionar operações”, o Dialog de “exibir o formulário” e o Hook do “fluxo de estado”.


Avançado: padrão Context para evitar prop drilling

Ao combinar vários componentes, um dos problemas mais comuns é o prop drilling: o estado é repassado camada após camada e, quando chega à quinta, você já não sabe de onde determinada prop veio.

Muitos componentes do próprio shadcn/ui usam o padrão Compound Components, como o Card:

<Card>
  <CardHeader>
    <CardTitle>Título</CardTitle>
    <CardDescription>Descrição</CardDescription>
  </CardHeader>
  <CardContent>Conteúdo</CardContent>
  <CardFooter>Rodapé</CardFooter>
</Card>

Ao ver essa estrutura aninhada, você talvez pense: “Como CardTitle sabe a qual Card pertence? Preciso passar um cardId?”

Não é necessário. O princípio central dos Compound Components é compartilhar o estado por Context, permitindo que os componentes filhos “saibam” automaticamente em qual componente pai estão.

Implemente um Card recolhível

O Card do shadcn/ui não é recolhível por padrão. Vamos estendê-lo com uma versão recolhível e, ao mesmo tempo, aprender o padrão Context:

// 1. Crie o Context
import &#123; createContext, useContext, useState &#125; from "react"

type CardContextValue = &#123;
  isCollapsed: boolean
  toggle: () => void
&#125;

const CardContext = createContext&lt;CardContextValue | null>(null)

// 2. Componente Root: gerencia o estado e fornece o Context
CollapsibleCard.Root = &#123; children, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardContext.Provider value=&#123;&#123;
      isCollapsed,
      toggle: () => setIsCollapsed(!isCollapsed)
    &#125;}>
      <Card className="border rounded-lg">&#123;children&#125;</Card>
    </CardContext.Provider>
  )
&#125;

// 3. Componente Header: exibe o título + botão de recolher
CollapsibleCard.Header = &#123; title &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Header must be in CollapsibleCard.Root")

  return (
    <CardHeader className="cursor-pointer" onClick={ctx.toggle}>
      <div className="flex items-center justify-between">
        <CardTitle>&#123;title&#125;</CardTitle>
        &#123;ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />&#125;
      </div>
    </CardHeader>
  )
&#125;

// 4. Componente Content: reage ao estado recolhido
CollapsibleCard.Content = &#123; children &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")

  if (ctx.isCollapsed) return null // Não exibir quando estiver recolhido

  return <CardContent>&#123;children&#125;</CardContent>
&#125;

Uso:

<CollapsibleCard.Root defaultCollapsed={false}>
  <CollapsibleCard.Header title="Informações do usuário" />
  <CollapsibleCard.Content>
    <p>Nome: Zhang San</p>
    <p>E-mail: [email protected]</p>
  </CollapsibleCard.Content>
</CollapsibleCard.Root>

Pontos principais:

  1. Compartilhamento de estado via Context: o componente Root cria o Context, e os componentes filhos obtêm o estado automaticamente com useContext, sem prop drilling.
  2. Os componentes filhos reagem automaticamente: um clique no Header alterna o estado e o Content aparece ou desaparece automaticamente, sem comunicação direta entre eles.
  3. Restrição obrigatória ao componente pai: se um componente filho não estiver dentro de Root, será lançado um erro para alertar você.

A vantagem desse padrão é que, ao combinar vários componentes, você não precisa se preocupar em como repassar o estado. Se o componente filho estiver dentro do pai, ele obterá o estado automaticamente.


Cenário completo: DataTable + Dialog + Form

Agora vamos reunir tudo o que aprendemos em uma página completa de gerenciamento de usuários: uma DataTable exibe a lista, um clique em “Editar” abre um Dialog, o Form fica dentro do Dialog e, depois do envio, a tabela é atualizada.

O exemplo de código completo aparece nas seções anteriores. Aqui está um resumo do fluxo principal:

  1. Definição do Schema: use o Zod para definir a estrutura dos dados do usuário e as regras de validação
  2. Hook de estado do Dialog: gerencie em um só lugar a abertura, o fechamento e a passagem de dados do Dialog
  3. Definição das colunas da DataTable: inclua uma coluna de ações com DropdownMenu
  4. Componente do formulário de edição: Form + FormField + diferentes Inputs
  5. Componente da página principal: combine a DataTable e o Dialog

Nesse exemplo completo, você encontra todos os padrões de composição:

  • A DataTable exibe os dados
  • O DropdownMenu aciona as operações
  • O Dialog exibe o formulário
  • O Form valida e envia os dados
  • O Hook gerencia o fluxo de estado

Cada componente tem uma responsabilidade clara, e o estado é gerenciado por Hook e Context, evitando prop drilling.


Técnicas avançadas: desempenho e segurança de tipos

Evite renderizações causadas pelo Context

Usar Context em Compound Components é muito conveniente, mas há uma armadilha: quando o valor do Context muda, todos os componentes que usam useContext são renderizados novamente.

No CollapsibleCard, por exemplo, Header e Content são renderizados novamente quando o estado de recolhimento muda. Se houver uma lista complexa dentro de Content, essa nova renderização pode ser lenta.

A solução é separar os Contexts.

// Context de estado (muda com frequência)
const CardStateContext = createContext&lt;&#123; isCollapsed: boolean &#125;>()

// Context de configuração (não muda)
const CardConfigContext = createContext&lt;&#123; collapsible: boolean &#125;>()

// O componente Root fornece os dois Contexts
CollapsibleCard.Root = &#123; children, collapsible = true, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardConfigContext.Provider value=&#123;&#123; collapsible &#125;}>
      <CardStateContext.Provider value=&#123;&#123; isCollapsed &#125;}>
        <Card>
          &#123;children&#125;
          {/* Coloque o botão Toggle separadamente para evitar que mudanças no Context afetem os componentes filhos */}
          &#123;collapsible && (
            <button onClick={() => setIsCollapsed(!isCollapsed)}>
              &#123;isCollapsed ? "Expandir" : "Recolher"&#125;
            </button>
          )&#125;
        </Card>
      </CardStateContext.Provider>
    </CardConfigContext.Provider>
  )
&#125;

Header lê apenas CardConfigContext, que não muda e não causa uma nova renderização. Content lê apenas CardStateContext, portanto é renderizado novamente somente quando o estado de recolhimento muda.

Segurança de tipos com TypeScript

É preciso ter cuidado ao definir os tipos de Compound Components. Caso contrário, o TypeScript pode informar que “o componente filho talvez não esteja dentro do componente pai”.

// Definições completas de tipos
type CollapsibleCardProps = &#123;
  children: React.ReactNode
  defaultCollapsed?: boolean
  collapsible?: boolean
&#125;

type CollapsibleCardComponents = &#123;
  Root: FC&lt;CollapsibleCardProps>
  Header: FC&lt;&#123; title: string &#125;>
  Content: FC&lt;&#123; children: React.ReactNode &#125;>
&#125;

const CollapsibleCard: CollapsibleCardComponents = &#123;
  Root: &#123; children, defaultCollapsed = false, collapsible = true &#125; => &#123;
    // ...
  &#125;,
  Header: &#123; title &#125; => &#123;
    // ...
  &#125;,
  Content: &#123; children &#125; => &#123;
    // ...
  &#125;
&#125;

Ao usar o componente dessa forma, o TypeScript verifica se as props fornecidas estão corretas:

// ✅ Correto
<CollapsibleCard.Root defaultCollapsed={true}>
  <CollapsibleCard.Header title="Título" />
  <CollapsibleCard.Content>Conteúdo</CollapsibleCard.Content>
</CollapsibleCard.Root>

// ❌ Erro do TypeScript: title é obrigatório
<CollapsibleCard.Header />

Resumo: checklist de padrões de composição

Depois de tudo isso, vamos resumir os princípios fundamentais:

Composições básicas

  • Dialog + Form: o Dialog é o contêiner, o Form é o conteúdo e as responsabilidades são separadas
  • DataTable + DropdownMenu: o DropdownMenu aciona operações e repassa os dados por row.original
  • Tabs + Form: Tabs faz a navegação, e cada TabsContent contém um formulário diferente

Técnicas avançadas

  • Padrão Context: evita prop drilling e permite que os componentes filhos obtenham o estado automaticamente
  • Estado gerenciado por Hook: centraliza o estado do Dialog e evita criar uma instância para cada linha
  • Form + Zod: centraliza o Schema de validação, e FormMessage exibe os erros automaticamente

Otimizações avançadas

  • Separação de Contexts: evita que estados alterados com frequência renderizem novamente todos os componentes filhos
  • Tipos TypeScript: definições completas evitam o envio de props incorretas
  • Separação entre Server e Client: no Next.js App Router, busque os dados no Server e mantenha a UI no Client

Uma última sugestão: não modifique diretamente os arquivos dos componentes do shadcn/ui. Para personalizar estilos, crie um componente wrapper, use variants ou faça a customização pelo theme. Alterar diretamente o código-fonte dificulta futuras atualizações.

Sinceramente, depois que aprendi os padrões de composição do shadcn/ui, meu código ficou muito mais organizado. Aquela página de gerenciamento de usuários caiu de mais de 300 para menos de 150 linhas, e o gerenciamento de estado também ficou mais claro. É verdade que técnicas avançadas como o padrão Context e a otimização de desempenho parecem um pouco confusas no começo, mas ficam naturais depois de algumas implementações.

Você já enfrentou algum problema parecido ao combinar componentes? Se sim, experimente estes padrões; eles devem ajudar a organizar o fluxo.


Implementar a composição DataTable + Dialog + Form

Fluxo completo de implementação de uma página de gerenciamento de usuários

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Defina o schema do Zod

    Defina a validação da estrutura de dados fora do componente:

    • Use z.object() para definir os campos
    • Adicione regras de validação (min, email e enum)
    • Exporte o schema e o type
  2. 2

    Step 2: Crie um Hook para o estado do Dialog

    Gerencie o estado do Dialog em um só lugar:

    • Use useState para gerenciar open e editingUser
    • openDialog abre o Dialog e recebe os dados
    • closeDialog fecha o Dialog e limpa os dados
  3. 3

    Step 3: Defina as colunas da DataTable

    Adicione uma coluna de ações em columns:

    • Coloque o DropdownMenu em cell
    • No onClick, chame openDialog(row.original)
    • Não aninhe o Dialog dentro de cell
  4. 4

    Step 4: Crie o componente do formulário de edição

    Use os componentes Form do shadcn:

    • useForm + zodResolver
    • FormField + FormControl
    • FormMessage exibe os erros automaticamente
  5. 5

    Step 5: Componha a página principal

    Combine todos os componentes:

    • DataTable + Dialog global
    • Coloque o Form dentro do Dialog
    • Atualize os dados da lista após o envio

FAQ

Por que não devo aninhar o Dialog dentro de uma cell da DataTable?
Criar uma instância de Dialog para cada linha causa problemas de desempenho. Cem linhas significam cem Dialogs, e o estado também fica difícil de gerenciar de forma centralizada. A abordagem correta é usar um único Dialog global e gerenciar seu estado com um Hook.
Devo usar React Hook Form ou gerenciar o formulário manualmente?
Recomendo fortemente React Hook Form + Zod:

• Validação e exibição de erros automáticas
• Segurança de tipos (inferência automática com z.infer)
• Melhor desempenho (menos renderizações)
• FormMessage exibe as mensagens de erro automaticamente
O padrão Context pode causar problemas de desempenho?
Sim. Quando o valor do Context muda, todos os componentes que usam useContext são renderizados novamente. Soluções:

• Separe os Contexts (Context de estado + Context de configuração)
• Faça apenas os componentes que precisam reagir ao estado lerem o Context de estado
• Coloque configurações imutáveis no Context de configuração
Como evitar prop drilling?
Use o padrão Context: o componente Root cria o Context e os componentes filhos obtêm o estado automaticamente com useContext. Não é preciso repassar props por várias camadas; basta que o componente filho esteja dentro do componente pai para acessar o estado.
Posso modificar diretamente o código-fonte dos componentes do shadcn/ui?
Não é recomendado. A abordagem correta é:

• Criar componentes wrapper
• Definir variações com variants
• Personalizar estilos pelo theme

Alterar diretamente o código-fonte dificulta futuras atualizações.
Como definir os tipos TypeScript de Compound Components?
Use definições de tipo completas:

• Defina os tipos de Props de Root, Header e Content
• Restrinja os componentes com FC<Props>
• Lance um erro se um componente filho estiver fora de Root

Assim, o TypeScript verifica se as props estão corretas.

14 min de leitura · Publicado em: 1 abr 2026 · Atualizado em: 8 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog