A tabela é o widget mais subestimado do Shiny. Todo dashboard tem uma, e a maioria é feita mal: renderTable cuspindo um HTML estático que trava com dez mil linhas, sem busca, sem ordenação, sem paginação. Uma boa tabela é interativa, rápida com grandes volumes, formatada com significado (cores, barras, ícones), editável quando preciso, e conectada ao resto do app (selecionar uma linha filtra um gráfico). Este módulo cobre os três grandes pacotes — DT, reactable e gt — e quando usar cada um.
Dominar tabelas interativas de produção: DT para grandes volumes com processamento no servidor, seleção e edição; reactable para tabelas modernas com formatação rica e sub-linhas; gt para tabelas de publicação. Aprender a atualizar tabelas sem recriá-las (dataTableProxy) e a conectá-las ao grafo reativo.
Escolhendo o pacote certo
Os três pacotes resolvem problemas parcialmente diferentes. Escolher errado gera retrabalho:
DT(wrapper do DataTables.js): o cavalo de batalha interativo. Busca, ordenação, paginação, seleção de linhas, edição de células, e — crucial — processamento no servidor para milhões de linhas. É o padrão para tabelas que interagem com o app.reactable(wrapper do React Table): tabelas modernas e bonitas, com formatação por célula (barras, cores, ícones), linhas expansíveis (detalhe aninhado), agrupamento e sparklines. Ótimo quando a estética e a formatação rica importam.gt(grammar of tables): tabelas de publicação — relatórios, PDFs, artigos. Cabeçalhos agrupados, rótulos, notas de rodapé, formatação tipográfica impecável. Menos interativo; é para exibir, não para interagir.
Regra rápida: interação e volume → DT; formatação rica e visual → reactable; relatório estático elegante → gt.
DT: o essencial interativo
library(DT)
library(shiny)
ui <- fluidPage(DTOutput("tab"))
server <- function(input, output, session) {
output$tab <- renderDT({
datatable(
iris,
filter = "top", # caixa de filtro por coluna
selection = "single", # 'single' | 'multiple' | 'none'
rownames = FALSE,
options = list(
pageLength = 10, # linhas por página
lengthMenu = c(10, 25, 50),
order = list(list(0, "desc")), # ordena pela 1ª coluna, desc
scrollX = TRUE # rolagem horizontal
)
)
})
}
shinyApp(ui, server)
Seleção de linha conectada ao app
O recurso que transforma uma tabela em ferramenta: ler qual linha o usuário selecionou via input$TABID_rows_selected.
server <- function(input, output, session) {
output$tab <- renderDT(
datatable(mtcars, selection = "single"), server = TRUE
)
# input$tab_rows_selected traz o(s) índice(s) da(s) linha(s) selecionada(s)
output$detalhe <- renderPrint({
sel <- input$tab_rows_selected
req(sel) # nada selecionado → para
mtcars[sel, ]
})
}
Os "inputs mágicos" do DT que você mais usará: input$tab_rows_selected (linhas selecionadas), input$tab_rows_all (todas as linhas após filtro), input$tab_cell_clicked (célula clicada), input$tab_search (texto da busca). São a ponte entre a tabela e o resto da reatividade.
Processamento no servidor: milhões de linhas
Por padrão, renderDT(..., server = TRUE) mantém os dados no servidor e envia ao navegador só a página visível. Isso é o que permite paginar, ordenar e buscar em datasets enormes sem travar o browser — o DataTables pede ao R apenas as ~10 linhas de cada página.
# server = TRUE (padrão em renderDT) → só a página atual trafega ao cliente
output$tab <- renderDT(
server = TRUE,
{
datatable(dados_gigantes, # milhões de linhas: sem problema
filter = "top",
options = list(pageLength = 25))
}
)
Contraste: server = FALSE manda tudo ao navegador de uma vez — aceitável para poucos milhares de linhas, desastroso para milhões. Regra: em dúvida, deixe server = TRUE.
Formatação e edição
output$tab <- renderDT({
datatable(vendas, editable = "cell") |> # dá para editar clicando na célula
formatCurrency("faturamento", currency = "R$ ", dec.mark = ",", mark = ".") |>
formatPercentage("margem", digits = 1) |>
formatStyle(
"faturamento",
background = styleColorBar(vendas$faturamento, "lightblue"), # mini-barra
color = styleInterval(1000, c("red", "green")) # cor por faixa
)
})
# Capturar edições: input$tab_cell_edit traz row, col e o novo value
observeEvent(input$tab_cell_edit, {
info <- input$tab_cell_edit
vendas[info$row, info$col] <<- DT::coerceValue(info$value, vendas[info$row, info$col])
})
formatStyle + styleColorBar/styleInterval dão as "mini-barras" e cores condicionais diretamente na célula, sem sair do R.
Atualização sem recriar: dataTableProxy
Como nos mapas e gráficos, recriar a tabela toda perde o estado (página atual, busca, ordenação). Para atualizar só os dados, use dataTableProxy + replaceData.
output$tab <- renderDT(datatable(dados_iniciais), server = TRUE)
proxy <- dataTableProxy("tab")
observeEvent(input$atualizar, {
replaceData(proxy, novos_dados, resetPaging = FALSE) # mantém a página atual
})
reactable: formatação rica e linhas expansíveis
Quando o objetivo é uma tabela visualmente rica — barras dentro das células, cores condicionais, ícones, e sobretudo linhas que expandem para mostrar detalhe — reactable brilha.
library(reactable)
reactable(
vendas,
searchable = TRUE, filterable = TRUE, defaultPageSize = 10,
striped = TRUE, highlight = TRUE,
columns = list(
faturamento = colDef(
name = "Faturamento",
cell = function(value) sprintf("R$ %.2f", value),
style = function(value) {
cor <- if (value > 1000) "#1a9850" else "#d73027"
list(color = cor, fontWeight = "bold")
}
),
margem = colDef(
name = "Margem",
cell = reactablefmt::data_bars(vendas, fill_color = "#5b9bd5") # barra na célula
)
),
# Linha expansível: detalhe aninhado
details = function(index) {
sub <- detalhes[detalhes$id == vendas$id[index], ]
reactable(sub, outlined = TRUE) # uma tabela dentro da linha!
}
)
No Shiny, reactable tem renderReactable/reactableOutput e um proxy próprio (updateReactable) para atualizar dados, seleção e expansão sem recriar. A seleção de linhas chega via getReactableState("tab", "selected").
output$tab <- renderReactable({
reactable(dados, selection = "multiple", onClick = "select")
})
observe({
sel <- getReactableState("tab", "selected") # índices selecionados
# ... usar 'sel' no resto do app
})
gt: tabelas de publicação
Para relatórios e documentos, gt produz tabelas com qualidade tipográfica de revista: título e subtítulo, grupos de colunas (spanners), notas de rodapé, formatação de números e destaque de células.
library(gt)
vendas |>
gt() |>
tab_header(
title = "Desempenho Trimestral",
subtitle = "Por região — 2024"
) |>
fmt_currency(columns = faturamento, currency = "BRL") |>
fmt_percent(columns = margem, decimals = 1) |>
tab_spanner(label = "Financeiro", columns = c(faturamento, margem)) |>
data_color(
columns = margem,
fn = scales::col_numeric("RdYlGn", domain = c(0, 1)) # heatmap na coluna
) |>
tab_footnote("Margem líquida após impostos.", locations = cells_column_labels(margem)) |>
tab_source_note("Fonte: sistema interno de vendas.")
No Shiny há render_gt/gt_output. gt é menos interativo (sem paginação/seleção nativas), então use-o para o painel de resumo de um dashboard, não para a tabela principal de interação.
Padrão de decisão e integração
Um dashboard maduro frequentemente usa os três: um gt de KPIs no topo (resumo estático elegante), um reactable para uma visão rica com detalhe expansível, e um DT como tabela-mestra que dirige o app (selecionar linha → filtra gráficos). Todos se conectam ao grafo reativo pela mesma lógica: a seleção vira um input, o resto do app lê esse input.
# DT como mestre: selecionar cliente filtra todos os gráficos
output$clientes <- renderDT(datatable(clientes, selection = "single"), server = TRUE)
cliente_sel <- reactive({
sel <- input$clientes_rows_selected
req(sel)
clientes$id[sel]
})
output$grafico <- renderPlotly({ plot_cliente(cliente_sel()) })
output$historico <- renderReactable({ reactable(hist_do(cliente_sel())) })
São três pacotes com três propósitos distintos: o DT para interação e volume, o reactable para formatação rica e linhas expansíveis, e o gt para publicação. O DT expõe o que se pode chamar de inputs mágicos — _rows_selected, _rows_all, _cell_clicked, _cell_edit —, e é por eles que a tabela conversa com a reatividade. O server = TRUE, que é o padrão no renderDT, processa no servidor e trafega só a página visível: é o que torna milhões de linhas viáveis. Para formatar, formatStyle com styleColorBar ou styleInterval dá barras e cores condicionais, e editable habilita a edição, que chega em _cell_edit. Atualizar sem recriar preserva página, busca e ordenação, e se faz com dataTableProxy e replaceData no DT, ou updateReactable no reactable — que por sua vez é quem entrega linhas expansíveis com tabelas aninhadas e barras na célula, com a seleção lida por getReactableState. O gt fica para o resumo estático elegante, não para a tabela de interação principal. E as três se integram pelo mesmo princípio: a seleção vira input e o app reage.
Exercícios
-
Você precisa exibir uma tabela de 3 milhões de linhas com busca e paginação sem travar o navegador. Qual pacote e qual configuração?
✓ Resposta:DTcomrenderDT(..., server = TRUE)(que é o padrão). No modo servidor, o DataTables mantém os dados no R e requisita ao servidor apenas a página visível (~10–25 linhas) por vez, junto com o resultado de busca/ordenação. Assim o navegador nunca recebe os 3 milhões de registros de uma vez. Usarserver = FALSEaqui enviaria tudo ao cliente e travaria — é o erro clássico. -
Qual a diferença de propósito entre
reactableegt? Dê um caso de uso para cada.✓ Resposta:reactableé para tabelas interativas e visualmente ricas — busca, filtro, ordenação, barras/cores por célula e, sobretudo, linhas expansíveis com detalhe aninhado. Caso de uso: uma tabela de vendas onde clicar numa linha expande os itens daquele pedido.gté para tabelas de publicação estática — qualidade tipográfica, cabeçalhos agrupados, notas de rodapé. Caso de uso: a tabela-resumo de KPIs de um relatório que vira PDF. Interação →reactable; documento elegante →gt. -
Como você lê, no servidor, qual linha o usuário selecionou numa tabela
DTchamada"clientes"? E como evita erro antes de qualquer seleção?✓ Resposta:Lê-se pelo input mágico
input$clientes_rows_selected, que traz o(s) índice(s) da(s) linha(s) selecionada(s) (respeitandoselection = "single"/"multiple"). Para evitar erro antes da primeira seleção — quando o valor éNULL— usa-sereq(input$clientes_rows_selected)no início doreactive/render, que aborta silenciosamente até haver seleção. -
Por que atualizar dados com
dataTableProxy/replaceDataé melhor que redesenhar a tabela dentro derenderDT?✓ Resposta:Redesenhar dentro de
renderDTrecria a tabela do zero, perdendo o estado da interface: a página atual volta à primeira, a busca digitada é apagada e a ordenação escolhida se perde.dataTableProxy+replaceData(proxy, novos, resetPaging = FALSE)substitui apenas os dados mantendo página, busca e ordenação — atualização mais suave e sem "pulos" visuais, o mesmo princípio deleafletProxy/plotlyProxy. -
Você quer uma tabela onde cada célula da coluna "margem" mostre uma mini-barra proporcional ao valor e fique verde se > 20% e vermelha caso contrário. Qual pacote e quais funções?
✓ Resposta:Com
DT, useformatStyle("margem", background = styleColorBar(dados$margem, "lightgreen"), color = styleInterval(0.20, c("red", "green")))—styleColorBardesenha a mini-barra estyleIntervaldefine a cor por faixa. Comreactable, use umcolDef(style = function(value) list(color = if (value > 0.20) "green" else "red"))combinado comreactablefmt::data_bars()para a barra. Ambos resolvem; a escolha depende do resto da tabela (interação/volume → DT; estética → reactable).