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 msqualquernenhum — o indicador padrão basta
0,3 a 2 sum painelesmaecimento padrão, ou spinner local
2 a 10 sum painelspinner local + botão desabilitado
2 a 10 so app todotela de carregamento sobre a página
> 10 s, com etapasqualquerbarra de progresso com etapa nomeada
> 30 squalquerprocesso 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

  1. Por que on.exit() é indispensável ao usar Progress$new() ou Waiter$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 do waiter, 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.

  2. 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.07 e 0.79. Se a etapa longa for divisível, melhor ainda é quebrá-la em sub-incrementos com detail dizendo o que está rodando.

  3. 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()).

  4. 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 pacote callr), que roda o trabalho em outro processo e libera a sessão.

  5. 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 = NULL a 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.

Fontes e leituras recomendadas