Até aqui a tabela foi tratada como saída: o app calcula, a tabela mostra. Agora vamos inverter o fluxo. A tabela vira entrada — o usuário digita numa célula, adiciona uma linha, apaga outra, e a aplicação precisa levar isso a sério: validar, persistir e não perder nada pelo caminho.
Esse é um salto de responsabilidade maior do que parece. Um gráfico errado mostra uma informação errada; uma tabela editável errada corrompe o dado. Por isso, metade desta aula é sobre os pacotes de edição, e a outra metade é sobre o ciclo completo de CRUD — criar, ler, atualizar, apagar — com as travas que impedem que uma digitação distraída vire um prejuízo.
Implementar edição de dados dentro do Shiny com DT e rhandsontable, escolher entre eles por critério, e montar um ciclo CRUD completo contra um banco, com validação, confirmação de exclusão, tratamento de concorrência e trilha de auditoria.
Três pacotes, três filosofias de edição
DT |
rhandsontable |
reactable |
|
|---|---|---|---|
| Metáfora | tabela web com células editáveis | planilha (tipo Excel) | tabela de leitura, edição via widget próprio |
| Edita | célula a célula, clique duplo | arrasta, copia/cola, preenche série | precisa de input embutido |
| Colunas tipadas | limitado | sim (numérico, data, dropdown, checkbox) | manual |
| Validação | no servidor, depois do evento | na célula, ao digitar | manual |
| Melhor para | correção pontual em tabela grande | entrada de dados em lote | exibição rica |
A regra prática: DT para corrigir, rhandsontable para preencher. Se o usuário vai ajustar três células numa tabela de mil linhas, DT. Se ele vai digitar uma grade de 40 linhas por 6 colunas como quem preenche uma planilha, rhandsontable — e vai agradecer pelo copiar-e-colar funcionar.
DT: edição pontual
Ligar a edição no DT é um argumento. O que importa é o que vem depois:
output$tabela <- DT::renderDT(
dados(),
editable = list(target = "cell", disable = list(columns = c(0, 1))),
selection = "single",
rownames = FALSE,
options = list(pageLength = 15)
)
observeEvent(input$tabela_cell_edit, {
info <- input$tabela_cell_edit # traz row, col e value
atual <- dados()
# DT conta colunas a partir de 0 quando rownames = FALSE
coluna <- names(atual)[info$col + 1]
novo <- DT::coerceValue(info$value, atual[[coluna]][info$row])
erro <- validar_celula(coluna, novo)
if (!is.null(erro)) {
showNotification(erro, type = "error")
DT::replaceData(DT::dataTableProxy("tabela"), atual, resetPaging = FALSE)
return()
}
atual[[coluna]][info$row] <- novo
dados(atual)
})
Quatro detalhes que separam o exemplo de brinquedo do código de produção:
disable = list(columns = ...)protege o identificador. Deixar o usuário editar a chave primária é convite a desastre.DT::coerceValue()converte a string que veio do navegador para o tipo da coluna original. Sem isso, uma coluna numérica vira texto na primeira edição e todo cálculo posterior quebra.DT::replaceData()no proxy desfaz visualmente a edição rejeitada. Sem ele, a tabela mostra o valor inválido mesmo tendo sido recusado no servidor — e o usuário acredita que salvou.resetPaging = FALSEmantém o usuário na página em que ele estava. Voltar para a página 1 a cada edição é um detalhe pequeno que deixa o app insuportável em tabelas grandes.
rhandsontable: a metáfora da planilha
O rhandsontable traz o Handsontable para o R e entrega o que o usuário de escritório espera: selecionar um bloco, colar do Excel, arrastar para preencher uma série, menu de contexto para inserir e remover linhas.
library(rhandsontable)
output$grade <- renderRHandsontable({
rhandsontable(dados(), height = 420, rowHeaders = NULL) |>
hot_col("produto", type = "dropdown", source = catalogo$nome, strict = TRUE) |>
hot_col("quantidade", type = "numeric", format = "0", validator = "
function (valor, retorno) {
retorno(valor === null || (Number.isInteger(valor) && valor >= 0));
}", allowInvalid = FALSE) |>
hot_col("preco", type = "numeric", format = "0,0.00") |>
hot_col("ativo", type = "checkbox") |>
hot_context_menu(allowRowEdit = TRUE, allowColEdit = FALSE)
})
A diferença conceitual em relação ao DT está no validator: a regra roda no navegador, na hora da digitação, e com allowInvalid = FALSE a célula nem aceita o valor errado. O usuário recebe o retorno imediato, em vez de descobrir o erro depois de salvar.
Isso não dispensa validar no servidor — nunca dispensa, porque o navegador é território do usuário e qualquer validação de cliente é contornável. Mas melhora muito a experiência de quem está digitando de boa-fé.
Para ler de volta:
observeEvent(input$grade, {
atual <- hot_to_r(input$grade) # devolve um data.frame
req(nrow(atual) > 0)
dados(atual)
})
Cuidado com o laço reativo.
input$gradedispara a cada edição; se oobserveEventescreve numreactiveValque orenderRHandsontablelê, a tabela se redesenha, o que pode disparar o evento de novo. OrenderRHandsontabledeve ler de uma fonte que não é atualizada por esse observer — ou useisolate()na leitura.
O ciclo CRUD contra um banco
Editar um data.frame em memória é a parte fácil. O que interessa é persistir. A arquitetura que uso tem quatro camadas bem separadas, e a separação é o que torna o app testável:
# 1. ACESSO — funções puras de banco, sem nada de Shiny dentro
listar_produtos <- function(con) {
DBI::dbGetQuery(con, "SELECT id, nome, preco, versao FROM produtos ORDER BY nome")
}
atualizar_produto <- function(con, id, nome, preco, versao) {
# A cláusula de `versao` é o controle de concorrência: se outra pessoa
# salvou entre a leitura e a gravação, nenhuma linha é afetada e sabemos.
n <- DBI::dbExecute(con,
"UPDATE produtos SET nome = ?, preco = ?, versao = versao + 1
WHERE id = ? AND versao = ?",
list(nome, preco, id, versao))
n == 1
}
# 2. VALIDAÇÃO — também pura, e testável sem banco nem navegador
validar_produto <- function(nome, preco) {
erros <- c()
if (!nzchar(trimws(nome))) erros <- c(erros, "Nome é obrigatório.")
if (nchar(nome) > 120) erros <- c(erros, "Nome excede 120 caracteres.")
if (is.na(preco) || preco < 0) erros <- c(erros, "Preço deve ser ≥ 0.")
erros
}
# 3. SERVIDOR — orquestra: valida, chama o acesso, dá retorno ao usuário
observeEvent(input$salvar, {
erros <- validar_produto(input$nome, input$preco)
if (length(erros)) {
showNotification(paste(erros, collapse = " "), type = "error")
return()
}
ok <- atualizar_produto(con, selecionado()$id, input$nome, input$preco, selecionado()$versao)
if (!ok) {
showNotification(
"Este registro foi alterado por outra pessoa. Recarregue antes de salvar.",
type = "warning", duration = NULL)
recarregar(recarregar() + 1)
return()
}
showNotification("Salvo.", type = "message")
recarregar(recarregar() + 1)
})
A quarta camada é a UI, que só conhece a terceira.
Exclusão: sempre com confirmação e, de preferência, sem apagar
Exclusão merece um parágrafo próprio porque é a operação irreversível:
observeEvent(input$excluir, {
req(selecionado())
showModal(modalDialog(
title = "Confirmar exclusão",
tagList(
p(sprintf("Excluir o produto \"%s\"?", selecionado()$nome)),
p(class = "text-danger mb-0", "Esta ação não pode ser desfeita.")
),
footer = tagList(
modalButton("Cancelar"),
actionButton("excluir_confirma", "Excluir", class = "btn-danger")
)
))
})
Duas travas que valem mais que a confirmação:
- Exclusão lógica. Em vez de
DELETE, umUPDATE ... SET excluido_em = NOW(), com as consultas filtrandoWHERE excluido_em IS NULL. O registro sai da vista sem sair do banco, e um engano é reversível com umUPDATE. - Chave estrangeira consciente. Se o produto tem pedidos associados, um
ON DELETE CASCADEapagaria o histórico de vendas junto. Para vínculos de histórico,RESTRICTé quase sempre a escolha certa: o banco impede a exclusão e o app explica ao usuário por quê.
Trilha de auditoria
Num app onde vários usuários editam, "quem mudou esse preço?" é a primeira pergunta que aparece. Registrar é barato:
registrar_auditoria <- function(con, usuario, tabela, registro_id, acao, antes, depois) {
DBI::dbExecute(con,
"INSERT INTO auditoria (usuario, tabela, registro_id, acao, antes, depois, em)
VALUES (?, ?, ?, ?, ?, ?, NOW())",
list(usuario, tabela, registro_id, acao,
jsonlite::toJSON(antes, auto_unbox = TRUE),
jsonlite::toJSON(depois, auto_unbox = TRUE)))
}
Guardar o estado anterior e o posterior em JSON custa pouco espaço e resolve praticamente qualquer investigação futura.
Transação: o detalhe que evita meio-salvamento
Quando uma operação toca mais de uma tabela — salvar o pedido e baixar o estoque, por exemplo —, ou tudo acontece ou nada acontece:
salvar_pedido <- function(con, pedido, itens) {
DBI::dbWithTransaction(con, {
DBI::dbExecute(con, "INSERT INTO pedidos (cliente_id, total) VALUES (?, ?)",
list(pedido$cliente_id, pedido$total))
id <- DBI::dbGetQuery(con, "SELECT LAST_INSERT_ID() AS id")$id
for (item in itens) {
DBI::dbExecute(con, "INSERT INTO pedido_itens (pedido_id, produto_id, qtd) VALUES (?,?,?)",
list(id, item$produto_id, item$qtd))
baixados <- DBI::dbExecute(con,
"UPDATE estoque SET saldo = saldo - ? WHERE produto_id = ? AND saldo >= ?",
list(item$qtd, item$produto_id, item$qtd))
if (baixados != 1) stop("Estoque insuficiente para o produto ", item$produto_id)
}
id
})
}
O stop() dentro do dbWithTransaction() desfaz tudo. Sem a transação, um erro no terceiro item deixaria o pedido gravado pela metade e o estoque errado — o tipo de inconsistência que só aparece semanas depois, na conferência.
A escolha entre os dois pacotes é de propósito: DT para corrigir células pontuais, rhandsontable para preencher uma grade como quem usa planilha, com copiar e colar, arrastar e colunas tipadas. No DT, o disable protege a chave, o coerceValue() preserva o tipo, o replaceData() no proxy desfaz visualmente a edição rejeitada e resetPaging = FALSE mantém o usuário onde estava. No rhandsontable, o validator valida no navegador durante a digitação e o allowInvalid = FALSE recusa ali mesmo na célula — o que melhora a experiência mas não substitui a validação no servidor, porque o cliente é território do usuário. Há um laço reativo a evitar: o observer que lê input$grade não pode alimentar a fonte que o render lê. O CRUD se organiza em quatro camadas — acesso, validação, servidor e UI —, e as duas primeiras, sendo puras, são testáveis sem banco e sem navegador. Para concorrência, uma coluna versao no WHERE do UPDATE: zero linhas afetadas quer dizer que outra pessoa salvou antes, e o certo é avisar, não sobrescrever. Na exclusão, confirmação em modal, preferência pela exclusão lógica e RESTRICT em vez de CASCADE para vínculos de histórico. E qualquer operação que toque várias tabelas vai dentro de dbWithTransaction(), onde um stop() desfaz tudo.
Exercícios
-
Depois da primeira edição numa coluna numérica com
DT, os cálculos do app passam a dar erro. O que aconteceu e como se corrige?✓ Resposta:O valor que chega em
input$tabela_cell_edit$valuevem do navegador como string. Atribuir essa string diretamente à coluna converte o vetor inteiro paracharacter, e qualquersum(),mean()ou comparação numérica posterior falha ou devolveNA. A correção é passar peloDT::coerceValue(info$value, valor_antigo), que converte a entrada para o tipo do valor original antes da atribuição — e vale conferir o resultado comis.na(), porque uma digitação não-numérica produzNAsilenciosamente. -
Por que rejeitar uma edição inválida apenas com
showNotificationé insuficiente noDT?✓ Resposta:Porque a tabela é renderizada no navegador e já mostrou o valor digitado antes de o servidor opinar. Se o servidor apenas exibe uma notificação e não devolve o dado, a célula continua exibindo o valor inválido: o usuário vê o número que digitou na tela e conclui que foi aceito, apesar do aviso. É preciso reverter visualmente, com
DT::replaceData(DT::dataTableProxy("tabela"), dados_validos, resetPaging = FALSE), para que a tela volte a refletir o estado real dos dados. -
Explique o controle de concorrência por coluna
versaoe o que exatamente sinaliza o conflito.✓ Resposta:Cada registro carrega um contador
versao. Ao carregar a tela, o app guarda a versão lida; ao salvar, oUPDATEincluiWHERE id = ? AND versao = ?com aquele valor, e incrementa a versão. Se outra pessoa salvou nesse intervalo, a versão no banco já mudou, a cláusula não casa e nenhuma linha é afetada. O sinal do conflito é justamente o retorno dedbExecute():0em vez de1. O app então avisa que o registro mudou e pede recarga, em vez de sobrescrever silenciosamente o trabalho alheio — que é o que aconteceria sem a cláusula. -
Quando
rhandsontableé a escolha melhor queDT, e qual armadilha reativa ele traz?✓ Resposta:Quando o usuário vai preencher dados em volume — uma grade de dezenas de linhas, com colunas tipadas, colando do Excel e arrastando para completar séries. A metáfora de planilha e o
validatorpor célula tornam a digitação muito mais rápida que o clique-duplo-por-célula doDT. A armadilha:input$gradedispara a cada edição; se o observer que o lê escreve na mesma fonte reativa que orenderRHandsontableconsome, a tabela se redesenha, podendo disparar o evento outra vez e criar um laço. A saída é o render ler de uma fonte que aquele observer não atualiza, ou envolver a leitura emisolate(). -
Por que
ON DELETE CASCADEcostuma ser errado entre produto e pedidos, e o que usar no lugar?✓ Resposta:Porque um pedido não deixa de existir quando o produto sai de catálogo — ele é registro histórico, contábil e muitas vezes fiscal. Com
CASCADE, excluir um produto apagaria em silêncio todo o histórico de vendas dele, destruindo faturamento passado e relatórios. O correto éRESTRICT: o banco impede a exclusão e o app explica ao usuário que existem pedidos vinculados. Melhor ainda é não excluir: marcar o produto como inativo (exclusão lógica), preservando o histórico e tirando-o das telas de seleção.CASCADEsó se justifica quando o filho realmente não faz sentido sem o pai — itens de um rascunho descartado, por exemplo.