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

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><CardHeader><CardTitle><CardContent>, enquanto um Dialog segue <Dialog><DialogContent><DialogHeader><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{{}}
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({...formData, username: e.target.value})}
/>
<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 { useForm } from "react-hook-form"
import { zodResolver } 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({ user, onSubmit }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(userSchema),
defaultValues: user // Passe os dados do usuário diretamente
})
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 {...form}>
<form onSubmit={form.handleSubmit((data) => {
onSubmit(data) // Enviar os dados
setOpen(false) // Fechar o Dialog
})}>
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Nome de usuário</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* Exibe os erros automaticamente */}
</FormItem>
)}
/>
<Button type="submit">Salvar</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
Pontos principais:
- 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.
- Use React Hook Form + Zod no Form: não gerencie o estado do formulário manualmente.
form.handleSubmitcuida automaticamente da validação e do envio. - 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 = [
{
id: "actions",
cell: { row } => (
<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>
)
}
]
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 = () => {
const [open, setOpen] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const openEdit = (user) => {
setEditingUser(user)
setOpen(true)
}
const closeEdit = () => {
setOpen(false)
setEditingUser(null)
}
return { open, editingUser, openEdit, closeEdit }
}
// 2. Coloque apenas o botão de acionamento na definição das colunas da DataTable
function UserDataTable({ users }) {
const { open, editingUser, openEdit, closeEdit } = useEditDialog()
const columns = [
{
id: "actions",
cell: { row } => (
<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>
)
}
]
return (
<>
<DataTable columns={columns} data={users} />
{/* Um único Dialog global */}
<Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
<DialogContent>
<EditUserForm
user={editingUser}
onSubmit={(data) => {
updateUser(data)
closeEdit()
refreshTable() // Atualizar os dados da tabela
}}
/>
</DialogContent>
</Dialog>
</>
)
}
Pontos principais:
- Dialog global: coloque um Dialog fora da DataTable, em vez de criar um para cada linha.
- O Hook gerencia o estado: openEdit abre o Dialog e recebe os dados; closeEdit fecha o Dialog e limpa os dados.
- 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 { createContext, useContext, useState } from "react"
type CardContextValue = {
isCollapsed: boolean
toggle: () => void
}
const CardContext = createContext<CardContextValue | null>(null)
// 2. Componente Root: gerencia o estado e fornece o Context
CollapsibleCard.Root = { children, defaultCollapsed = false } => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardContext.Provider value={{
isCollapsed,
toggle: () => setIsCollapsed(!isCollapsed)
}}>
<Card className="border rounded-lg">{children}</Card>
</CardContext.Provider>
)
}
// 3. Componente Header: exibe o título + botão de recolher
CollapsibleCard.Header = { title } => {
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>{title}</CardTitle>
{ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />}
</div>
</CardHeader>
)
}
// 4. Componente Content: reage ao estado recolhido
CollapsibleCard.Content = { children } => {
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>{children}</CardContent>
}
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:
- 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.
- 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.
- 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:
- Definição do Schema: use o Zod para definir a estrutura dos dados do usuário e as regras de validação
- Hook de estado do Dialog: gerencie em um só lugar a abertura, o fechamento e a passagem de dados do Dialog
- Definição das colunas da DataTable: inclua uma coluna de ações com DropdownMenu
- Componente do formulário de edição: Form + FormField + diferentes Inputs
- 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<{ isCollapsed: boolean }>()
// Context de configuração (não muda)
const CardConfigContext = createContext<{ collapsible: boolean }>()
// O componente Root fornece os dois Contexts
CollapsibleCard.Root = { children, collapsible = true, defaultCollapsed = false } => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardConfigContext.Provider value={{ collapsible }}>
<CardStateContext.Provider value={{ isCollapsed }}>
<Card>
{children}
{/* Coloque o botão Toggle separadamente para evitar que mudanças no Context afetem os componentes filhos */}
{collapsible && (
<button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Expandir" : "Recolher"}
</button>
)}
</Card>
</CardStateContext.Provider>
</CardConfigContext.Provider>
)
}
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 = {
children: React.ReactNode
defaultCollapsed?: boolean
collapsible?: boolean
}
type CollapsibleCardComponents = {
Root: FC<CollapsibleCardProps>
Header: FC<{ title: string }>
Content: FC<{ children: React.ReactNode }>
}
const CollapsibleCard: CollapsibleCardComponents = {
Root: { children, defaultCollapsed = false, collapsible = true } => {
// ...
},
Header: { title } => {
// ...
},
Content: { children } => {
// ...
}
}
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
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
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
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
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
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?
Devo usar React Hook Form ou gerenciar o formulário manualmente?
• 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?
• 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?
Posso modificar diretamente o código-fonte dos componentes do shadcn/ui?
• 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?
• 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
Tailwind e shadcn/ui na prática
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Modo escuro no Tailwind: comparação entre class e data-theme
Comparação completa entre as estratégias class e data-theme para o modo escuro no Tailwind CSS, com princípios de funcionamento, configuração e integração prática com frameworks para ajudar você a escolher a melhor opção para o projeto
Parte 7 de 14
Próximo
shadcn/ui e Radix: como manter a acessibilidade ao personalizar componentes
O shadcn/ui usa Radix Primitives como base. Entenda como preservar a acessibilidade ao personalizar componentes, usar asChild, gerenciar o foco e manter os atributos ARIA para não comprometer a navegação por teclado.
Parte 9 de 14



Comentários
Entre com GitHub para comentar