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áforatabela web com células editáveisplanilha (tipo Excel)tabela de leitura, edição via widget próprio
Editacélula a célula, clique duploarrasta, copia/cola, preenche sérieprecisa de input embutido
Colunas tipadaslimitadosim (numérico, data, dropdown, checkbox)manual
Validaçãono servidor, depois do eventona célula, ao digitarmanual
Melhor paracorreção pontual em tabela grandeentrada de dados em loteexibiçã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 = FALSE manté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$grade dispara a cada edição; se o observeEvent escreve num reactiveVal que o renderRHandsontable lê, a tabela se redesenha, o que pode disparar o evento de novo. O renderRHandsontable deve ler de uma fonte que não é atualizada por esse observer — ou use isolate() 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, um UPDATE ... SET excluido_em = NOW(), com as consultas filtrando WHERE excluido_em IS NULL. O registro sai da vista sem sair do banco, e um engano é reversível com um UPDATE.
  • Chave estrangeira consciente. Se o produto tem pedidos associados, um ON DELETE CASCADE apagaria 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

  1. 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$value vem do navegador como string. Atribuir essa string diretamente à coluna converte o vetor inteiro para character, e qualquer sum(), mean() ou comparação numérica posterior falha ou devolve NA. A correção é passar pelo DT::coerceValue(info$value, valor_antigo), que converte a entrada para o tipo do valor original antes da atribuição — e vale conferir o resultado com is.na(), porque uma digitação não-numérica produz NA silenciosamente.

  2. Por que rejeitar uma edição inválida apenas com showNotification é insuficiente no DT?

    ✓ 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.

  3. Explique o controle de concorrência por coluna versao e 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, o UPDATE inclui WHERE 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 de dbExecute(): 0 em vez de 1. 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.

  4. Quando rhandsontable é a escolha melhor que DT, 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 validator por célula tornam a digitação muito mais rápida que o clique-duplo-por-célula do DT. A armadilha: input$grade dispara a cada edição; se o observer que o lê escreve na mesma fonte reativa que o renderRHandsontable consome, 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 em isolate().

  5. Por que ON DELETE CASCADE costuma 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. CASCADE só se justifica quando o filho realmente não faz sentido sem o pai — itens de um rascunho descartado, por exemplo.

Fontes e leituras recomendadas