Learn R Programming

rewind

Undo and redo for Shiny applications.

Shiny has bookmarking. It captures a state that you can send as a link. Shiny also has reactlog. It lets you replay a session while you debug. But Shiny has no undo. Users expect undo, because each other application has it.

rewind adds undo in one line.

library(shiny)
library(rewind)

ui <- fluidPage(
  rewind_buttons(),
  selectInput("region", "Region", c("North", "South", "East", "West")),
  sliderInput("year", "Year", 2018, 2026, 2024),
  plotOutput("plot")
)

server <- function(input, output, session) {
  rewind_enable()          # <- that's it

  output$plot <- renderPlot(plot_for(input$region, input$year))
}

shinyApp(ui, server)

Ctrl+Z now moves backwards through the filter choices of the user. Ctrl+Shift+Z moves forwards.

Keyboard shortcuts

KeysAction
Ctrl + Z (Cmd + Z on macOS)Undo
Ctrl + Shift + Z (Cmd + Shift + Z on macOS)Redo
Ctrl + YRedo

There are two shortcuts for redo. Ctrl + Y is the usual one on Windows. Cmd + Shift + Z is the usual one on macOS.

The shortcuts do nothing while the user types in a text field. The text undo of the browser thus continues to work. Set rewind_enable(shortcuts = FALSE) to turn the shortcuts off.

Installation

install.packages("rewind")

To get the development version from GitHub:

# install.packages("remotes")
remotes::install_github("tenmeh/rewind")

Then run the demo:

shiny::runApp(system.file("examples/demo", package = "rewind"))

Building from source

roxygen2 makes NAMESPACE and man/. Do not edit those files. Edit the roxygen comments above each function. Then make the documents again:

devtools::document()      # rewrites NAMESPACE and man/ from the roxygen comments
devtools::test()          # runs tests/testthat
devtools::check()         # full R CMD check

What it does

Grouping, so that undo is useful

One movement of a slider sends many input events. An undo of each event is of no use. Changes that occur within coalesce_ms (the default is 400) of each other become one history entry. One movement is thus one undo step.

Your own steps, when time is not the correct limit

A "reset filters" button changes four inputs together. It must make one entry, with a name that a person wrote:

observeEvent(input$reset, {
  rewind_step(label = "Reset filters", {
    updateSelectInput(session, "region", selected = "All")
    updateSliderInput(session, "year", value = c(2018, 2026))
    updateCheckboxInput(session, "active_only", value = FALSE)
  })
})

State on the server, and not only inputs

rewind cannot see the values that you keep in reactiveValues. Register them:

state <- reactiveValues(pinned = character(0), zoom = 1)
rewind_track(state, fields = c("pinned", "zoom"))

A history rail

rewind_ui() draws the stack as a scrubbable list. Click a step to move to it. The labels come from the values that changed. The rail thus shows region, year and not state 7.

ui <- fluidPage(
  sidebarLayout(
    sidebarPanel(rewind_ui()),
    mainPanel(...)
  )
)

How restore works

Read this part if you intend to extend the package.

To restore a value, you must put it back in the widget. The simple method is a lookup table: sliderInput -> updateSliderInput, selectInput -> updateSelectInput, and one entry for each other input type. That table is never complete. It fails when a person uses a widget from a package that you do not know.

rewind thus has no table. Each Shiny input registers an input binding on its DOM element. Each binding gives a setValue() or a receiveMessage() function. The code in the browser finds the binding and calls that function:

var binding = $el.data("shiny-input-binding");
if (typeof binding.setValue === "function") {
  binding.setValue(el, value);
} else {
  binding.receiveMessage(el, { value: value });
}

That is the full restore procedure. It also operates on inputs from other packages, with no extra code.

The echo problem

A restore sends the value to the browser. The browser sets the value. It then sends the new value back to the server. There, the capture observer sees a change and writes a new history entry. This is a loop. Packages of this type usually fail because of it.

Two methods stop the loop. They overlap on purpose:

  1. Dedup. The history does not accept a state that is the same as the current state. A restore moves the position to state S before the echo arrives. The echo is thus the same as the current state, and the history drops it. This method stops the loop in the usual conditions, and it needs no other code.
  2. An expectation guard. The controller keeps the state that it sent to the browser. It ignores the states between until the echo agrees, or until two seconds pass. This method stops the loop when the inputs arrive in different flushes. Those inputs make a partial state, and dedup does not find it.

Neither method assumes a time for the trip to the browser and back. The package is thus dependable, and not only usually correct.

What rewind does not capture

  • Action buttons and links. Their value is a click counter. A restore does nothing, or it starts the observers again by mistake.
  • fileInput(). Its value points to a temporary file on the server. Shiny deletes that file at the next upload. An old snapshot would thus point to a file that does not exist.
  • Inputs with names that start with .. These belong to Shiny.
  • Inputs with names that start with rewind_. These belong to this package.

Known limits

These are the limits of the package. Read them before you start:

  • Values go through JSON. Dates come back as text, and integers come back as doubles. The comparison accepts this. But an input that holds an unusual R object does not survive. Keep such state in reactiveValues and track it there. No serialisation occurs there.
  • Modules. You can call rewind_enable() inside a moduleServer(). This operation has tests. rewind captures the inputs with their module-local names. These are the same names that input$ uses inside the module. rewind adds the namespace with session$ns() at a restore. All modules share session$userData. A call in a second module thus uses the same history. Call the function once, at the position in the module tree that is best for your application.
  • Undo does not undo side effects. An observer can write to a database when a filter changes. An undo then changes the filter, but not the database.
  • Inputs that renderUI makes. rewind captures them after they exist. An undo to a state before they existed keeps their current values.

API

FunctionPurpose
rewind_enable()Start capture for the session
rewind_track()Add reactiveValues fields to the history
rewind_step()Put several changes into one entry with a label
rewind_undo(), rewind_redo(), rewind_jump()Move through the history from your own controls
rewind_clear()Remove each entry but the current one
rewind_pause(), rewind_resume()Stop capture around changes that your code makes
rewind_disable()Stop capture for the session completely
rewind_history(), rewind_can_undo(), rewind_can_redo()Read the history in a reactive context
rewind_buttons(), rewind_ui()Ready-made controls

License

MIT

Copy Link

Version

Install

install.packages('rewind')

Version

0.3.0

License

MIT + file LICENSE

Issues

Pull Requests

Stars

Forks

Maintainer

Tanmay Chanda

Last Published

September 25th, 2026

Functions in rewind (0.3.0)

rewind_step

Group several changes into one undo step
rewind_track

Include server-side reactive values in the history
rewind_ui

A scrubbable history rail
rewind_undo

Move through the history programmatically
rewind-package

rewind: Undo and Redo for 'Shiny' Applications
rewind_dependency

The rewind HTML dependency
rewind_pause

Suspend and resume history capture
rewind_diff

Show what changed between two steps
rewind_disable

Fully disable undo/redo for a session
rewind_history

Inspect the history
rewind_enable

Enable undo and redo for a Shiny session
rewind_buttons

Undo and redo buttons