Todo app tem operações lentas. A consulta pesada, o modelo que roda, o relatório que se monta. O que separa uma aplicação profissional de uma amadora não é a ausência de espera — é o que acontece durante a espera.
Sem feedback, o usuário clica, nada muda na tela por seis segundos, e ele conclui uma destas três coisas: que o botão não funcionou (e clica de novo, disparando a operação duas vezes), que a internet caiu, ou que o sistema é ruim. Nenhuma é verdade, e todas são culpa da interface.
O Shiny tem um indicador embutido — ele esmaece as saídas que estão recalculando —, mas ele é discreto demais para operações longas e não diz o que está acontecendo nem quanto falta. Esta aula é sobre fazer melhor.
Escolher e implementar o tipo certo de feedback de espera para cada situação: barra de progresso com etapas nomeadas, tela de carregamento que cobre a aplicação, esqueleto de conteúdo, e desabilitação do gatilho — evitando tanto a ausência de retorno quanto o excesso de ruído visual.
Escolher o feedback pelo tempo e pelo escopo
Duas perguntas decidem quase tudo:
| Duração | Escopo | Feedback adequado |
|---|---|---|
| < 300 ms | qualquer | nenhum — o indicador padrão basta |
| 0,3 a 2 s | um painel | esmaecimento padrão, ou spinner local |
| 2 a 10 s | um painel | spinner local + botão desabilitado |
| 2 a 10 s | o app todo | tela de carregamento sobre a página |
| > 10 s, com etapas | qualquer | barra de progresso com etapa nomeada |
| > 30 s | qualquer | processo assíncrono + notificação ao terminar |
A última linha aponta para outra aula (processamento assíncrono). As demais resolvem-se com o que vem a seguir.
O erro mais comum é aplicar tela cheia em operação de meio segundo: o flash de uma sobreposição que aparece e some instantaneamente incomoda mais que a espera.
withProgress: a barra que conta a história
Para operações com etapas identificáveis, o Shiny traz withProgress()/incProgress(). A vantagem sobre um spinner genérico é que o usuário sabe o que está acontecendo:
observeEvent(input$processar, {
withProgress(message = "Processando", value = 0, {
incProgress(0.15, detail = "lendo os arquivos…")
bruto <- ler_arquivos(input$pasta)
incProgress(0.35, detail = "limpando e validando…")
limpo <- validar(bruto)
incProgress(0.30, detail = "calculando indicadores…")
resultado <- calcular(limpo)
incProgress(0.20, detail = "montando a saída…")
dados(resultado)
})
})
Os incrementos somam 1. Escolha-os proporcionais ao tempo real de cada etapa, não ao número de etapas — uma barra que vai a 80% em meio segundo e leva dez segundos nos 20% finais é pior que nenhuma barra, porque promete e não cumpre.
Para laços, Progress$new() dá controle mais fino:
progresso <- Progress$new(session, min = 0, max = length(arquivos))
on.exit(progresso$close()) # fecha mesmo se der erro no meio
for (i in seq_along(arquivos)) {
progresso$set(value = i, message = "Importando",
detail = sprintf("%d de %d — %s", i, length(arquivos), basename(arquivos[i])))
importar(arquivos[i])
}
O on.exit() não é opcional. Sem ele, um erro dentro do laço deixa a barra travada na tela para sempre, e o usuário fica olhando um progresso congelado sem saber que a operação já morreu.
waiter: tela cheia, painel e esqueleto
O pacote waiter cobre o que o Shiny não cobre: sobreposições sobre a página inteira, sobre um painel específico, e esqueletos de conteúdo. São três ferramentas distintas.
Tela de abertura — mostrada enquanto o app inicializa, e escondida quando ele está pronto:
library(waiter)
ui <- fluidPage(
useWaiter(),
waiterShowOnLoad(
html = tagList(spin_flower(), h4("Preparando o painel…")),
color = "#1a202c"
),
# … o resto da UI
)
server <- function(input, output, session) {
dados_iniciais <- carregar_base() # a parte lenta da inicialização
waiter_hide() # só some quando de fato terminou
}
Sobreposição em um painel — o resto do app continua utilizável:
w <- Waiter$new(id = "grafico", html = spin_three_bounce(), color = "rgba(255,255,255,0.85)")
observeEvent(input$recalcular, {
w$show()
on.exit(w$hide()) # de novo: garante o fim mesmo com erro
dados(calcular_pesado(input$parametros))
})
Esqueleto — em vez de um spinner, o contorno cinza do conteúdo que está vindo. É o padrão que redes sociais adotaram porque a página não "pula" quando o conteúdo chega:
ui <- fluidPage(
use_hostess(),
card(plotOutput("grafico") |> skeleton_load())
)
Se for usar só uma coisa do waiter, use o on.exit(w$hide()). Sobreposição que não some é pior que sobreposição nenhuma: ela bloqueia a interface inteira e obriga o usuário a recarregar a página.
Desabilitar o gatilho: o feedback mais barato
Antes de qualquer biblioteca, existe uma medida que resolve o problema mais grave da espera — o clique duplo que dispara a operação duas vezes:
observeEvent(input$gerar, {
shinyjs::disable("gerar")
on.exit(shinyjs::enable("gerar"))
relatorio(montar_relatorio(input$periodo))
})
Dois cliques num botão de "gerar relatório" podem significar dois PDFs, dois e-mails disparados ou duas gravações no banco. Desabilitar custa duas linhas e elimina a classe inteira de problema.
Melhor ainda é combinar com um rótulo que muda:
observeEvent(input$gerar, {
shinyjs::disable("gerar")
updateActionButton(session, "gerar", label = "Gerando…")
on.exit({
shinyjs::enable("gerar")
updateActionButton(session, "gerar", label = "Gerar relatório")
})
# …
})
O botão acinzentado escrito "Gerando…" comunica estado sem ocupar espaço nenhum na tela.
Carregamento sequencial: mostrar o que já chegou
Quando um painel tem várias seções de custos diferentes, renderizá-las na ordem em que ficam prontas é muito melhor que esperar a mais lenta para mostrar tudo de uma vez. O usuário começa a ler enquanto o resto carrega.
O mecanismo é reactiveVal por seção, com cada uma alimentada por seu próprio observe:
server <- function(input, output, session) {
kpis <- reactiveVal(NULL)
grafico <- reactiveVal(NULL)
detalhes <- reactiveVal(NULL)
observeEvent(input$carregar, {
kpis(NULL); grafico(NULL); detalhes(NULL)
# rápido: aparece quase imediatamente
kpis(calcular_kpis(input$periodo))
# médio
grafico(montar_serie(input$periodo))
# lento: chega por último, sem segurar os outros
detalhes(consultar_detalhes(input$periodo))
})
output$kpis <- renderUI({
if (is.null(kpis())) return(div(class = "placeholder-glow",
span(class = "placeholder col-12")))
montar_cards(kpis())
})
}
Uma ressalva honesta: numa sessão Shiny, o R é single-threaded. Esse padrão faz as seções aparecerem em ordem, mas não as calcula em paralelo — a soma dos tempos é a mesma. O ganho é de percepção, e é real: ver o primeiro número em 1 segundo muda a experiência mesmo que o total continue sendo 8. Paralelismo de verdade exige processamento assíncrono, assunto de outra aula.
Mensagem de erro também é feedback
O caso que ninguém planeja: a operação lenta falha. Se o tratamento cobre só o sucesso, o spinner some e a tela fica vazia, sem explicação.
observeEvent(input$processar, {
w$show(); on.exit(w$hide())
resultado <- tryCatch(
operacao_pesada(input$parametros),
error = function(e) {
showNotification(
paste("Não foi possível concluir:", conditionMessage(e)),
type = "error", duration = NULL
)
NULL
}
)
req(resultado)
dados(resultado)
})
O duration = NULL mantém a notificação de erro na tela até o usuário fechá-la. Erro que some sozinho em cinco segundos é erro que ninguém leu.
O feedback se escolhe por duração e escopo: abaixo de 300 ms, nenhum; painel lento pede spinner local; o app inteiro pede tela cheia; etapas longas pedem barra de progresso nomeada; e acima de 30 segundos, o caminho é assíncrono. O withProgress com incProgress e detail conta o que está acontecendo, e os incrementos devem ser distribuídos pelo tempo real de cada etapa, não pelo número delas. Ao usar Progress$new(), o on.exit(progresso$close()) não é opcional: sem ele, um erro deixa a barra congelada na tela para sempre. O waiter cobre os três escopos — tela cheia, painel e esqueleto — e vale a mesma disciplina do on.exit(w$hide()), porque sobreposição que não some trava o app. O feedback mais barato de todos é desabilitar o botão durante a operação, e ele resolve o problema mais grave: o clique duplo que executa duas vezes; combine com um rótulo que muda. Uma expectativa a calibrar: carregamento sequencial melhora a percepção, não o tempo total, porque o R é single-threaded por sessão — paralelismo real exige assíncrono. E trate o erro da operação lenta, senão o spinner some e a tela fica vazia; use duration = NULL para que a mensagem de erro não desapareça antes de ser lida.
Exercícios
-
Por que
on.exit()é indispensável ao usarProgress$new()ouWaiter$new()?✓ Resposta:Porque garante o encerramento mesmo quando a operação falha. Se o fechamento (
progresso$close()/w$hide()) fica apenas na última linha do bloco, qualquer erro no meio interrompe a execução antes de chegar lá — e a barra ou a sobreposição permanecem na tela indefinidamente. No caso dowaiter, a sobreposição cobre a interface inteira, então o usuário fica sem conseguir clicar em nada e precisa recarregar a página.on.exit()registra a limpeza para rodar na saída da função, com erro ou sem. -
Um colega distribuiu
incProgress(0.25)em quatro etapas cujos tempos reais são 1s, 1s, 1s e 12s. Qual o problema e como corrigir?✓ Resposta:A barra chega a 75% em três segundos e depois fica parada nos 25% finais por doze segundos. Isso é pior que não ter barra: ela cria a expectativa de que falta pouco e então parece travada, levando o usuário a achar que o app congelou — e possivelmente a recarregar, cancelando tudo. Os incrementos devem ser proporcionais ao tempo, não ao número de etapas: com 15 s no total, algo como
0.07,0.07,0.07e0.79. Se a etapa longa for divisível, melhor ainda é quebrá-la em sub-incrementos comdetaildizendo o que está rodando. -
Qual problema concreto a desabilitação do botão resolve, e por que ele é mais grave que a falta de feedback visual?
✓ Resposta:Resolve o clique duplo. Sem retorno imediato, o usuário presume que o primeiro clique não registrou e clica de novo, disparando a operação duas vezes. Enquanto a falta de feedback é um problema de experiência — o usuário espera sem saber —, a execução dupla é um problema de integridade: dois registros gravados no banco, dois e-mails enviados, duas cobranças, dois PDFs. O primeiro incomoda; o segundo corrompe dado e pode ter consequência real para o negócio. E a correção custa duas linhas com
shinyjs::disable()+on.exit(shinyjs::enable()). -
O padrão de carregamento sequencial realmente acelera o app? Explique.
✓ Resposta:Não acelera o tempo total. Numa sessão Shiny o R é single-threaded: as seções continuam sendo calculadas uma após a outra, e a soma dos tempos é idêntica. O que muda é a percepção — o usuário vê o primeiro bloco em 1 segundo em vez de olhar para uma tela vazia por 8. Isso é um ganho real de experiência, e frequentemente suficiente, mas é honesto chamá-lo pelo nome. Para reduzir o tempo total de fato é preciso processamento assíncrono (
promises/future, ou o pacotecallr), que roda o trabalho em outro processo e libera a sessão. -
Por que a notificação de erro de uma operação longa deve usar
duration = NULL?✓ Resposta:Porque a notificação padrão desaparece sozinha após alguns segundos. No caso de uma operação longa, é bastante provável que o usuário tenha saído da frente da tela ou ido olhar outra coisa enquanto esperava — e volte para encontrar a tela vazia, sem qualquer indicação do que houve. Com
duration = NULLa mensagem fica até ser fechada explicitamente, garantindo que o motivo da falha seja lido. Pela mesma razão, a mensagem deve dizer o que aconteceu em linguagem de usuário, e não apenas repetir o texto técnico da exceção.