Over the years consulting on Shiny projects, two JavaScript functions have been very helpful to me and serve a core role in applications. Thanks so much to the project sponsor who has generously allowed me to share this code.
isInputChanged
On the R
side, observers and reactives are only triggered when an input changes. However JavaScript’s shiny:inputchanged
often will trigger when an input updates even if nothing changes. isInputChanged
lets us know if the input changed. This means we can ignore unchanged inputs in JavaScript, especially for expensive or cumbersome operations.
// an imperfect function to see if Shiny input has changed
function isInputChanged(evt) {
if(Shiny && Shiny.hasOwnProperty("shinyapp") && Shiny.shinyapp.hasOwnProperty("$inputValues")) {
var oldvalue = null;
for(var ky in Shiny.shinyapp.$inputValues) { // use for to only find first match and exit early
if(ky.replace(/(.*)(:.*)/,"$1") === evt.name) { // handle :binding in name
oldvalue = Shiny.shinyapp.$inputValues[ky];
break;
}
}
return evt.value !== oldvalue;
} else {
return false;
}
}
cancelInput
Sometimes, we will want to validate a user’s change before setting off the waterfall of reactive dependencies or doing consequential things. For instance, we might want to show a modal asking the user for confirmation before overwriting data. Often, I pair cancelInput
with isInputChanged
.
// similar to changedInput to cancel a shiny input change
// by restoring its original value
function cancelInput(evt) {
if(Shiny && Shiny.hasOwnProperty("shinyapp") && Shiny.shinyapp.hasOwnProperty("$inputValues")) {
var oldvalue = null;
var newvalue = evt.value;
for(var ky in Shiny.shinyapp.$inputValues) { // use for to only find first match and exit early
if(ky.replace(/(.*)(:.*)/,"$1") === evt.name) { // handle :binding in name
oldvalue = Shiny.shinyapp.$inputValues[ky];
break;
}
}
if(evt.value !== oldvalue) {
evt.value = oldvalue;
}
}
// cancel event
evt.preventDefault();
return {
event: evt,
oldvalue: oldvalue,
newvalue: newvalue
};
}
Example Usage
Altogether now, let’s see how this might look in a simple contrived app in which a user selects a letter. When the user selects a different letter, we confirm the change with a modal in which the user can accept or reject their own change. The code is below, or through the miraculous shinylive
we can see a live example in the browser shinylive example.
library(shiny)
library(bslib)
ui <- page_fluid(
selectizeInput(
inputId = 'letterselector',
label = "Pick a Letter",
choices = letters[1:4],
selected = letters[1]
),
# quick little modal adapted from https://getbootstrap.com/docs/5.2/components/modal/
HTML(
'
<div class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Changed Letter Selection</h5>
</div>
<div class="modal-body">
<p>Are you sure we should change the letter?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-no" data-bs-dismiss="modal">No</button>
<button type="button" class="btn btn-primary btn-yes">Yes</button>
</div>
</div>
</div>
</div>
'
),
# quick little modal adapted from https://getbootstrap.com/docs/5.2/components/modal/
tags$head(tags$script(HTML(
'
// an imperfect function to see if Shiny input has changed
function isInputChanged(evt) {
if(Shiny && Shiny.hasOwnProperty("shinyapp") && Shiny.shinyapp.hasOwnProperty("$inputValues")) {
var oldvalue = null;
for(var ky in Shiny.shinyapp.$inputValues) { // use for to only find first match and exit early
if(ky.replace(/(.*)(:.*)/,"$1") === evt.name) { // handle :binding in name
oldvalue = Shiny.shinyapp.$inputValues[ky];
break;
}
}
return evt.value !== oldvalue;
} else {
return false;
}
}
// similar to changedInput to cancel a shiny input change
// by restoring its original value
function cancelInput(evt) {
if(Shiny && Shiny.hasOwnProperty("shinyapp") && Shiny.shinyapp.hasOwnProperty("$inputValues")) {
var oldvalue = null;
var newvalue = evt.value;
for(var ky in Shiny.shinyapp.$inputValues) { // use for to only find first match and exit early
if(ky.replace(/(.*)(:.*)/,"$1") === evt.name) { // handle :binding in name
oldvalue = Shiny.shinyapp.$inputValues[ky];
break;
}
}
if(evt.value !== oldvalue) {
evt.value = oldvalue;
}
}
// cancel event
evt.preventDefault();
return {
event: evt,
oldvalue: oldvalue,
newvalue: newvalue
};
}
'
))),
# use JS functions to confirm user change with modal
tags$script(HTML(
'
$(document).on("shiny:inputchanged", function(evt) {
if(evt.name === "letterselector" && isInputChanged(evt)) {
(function() {
// cancel input since we want control based on user choice
// return value will be an object with event, original value, and new value
var letter = cancelInput(evt);
// show modal confirming what user would like to overwrite
const myModal = new bootstrap.Modal($(".modal"));
var modalCancelHandler = function() {
// if cancelled then we will need to set the selectize back to where it was
// the input should already match the old value since we cancelled
$("#letterselector")[0].selectize.setValue(letter.oldvalue);
// clean up after ourselves and remove handlers
$(".modal .btn-no").off("click", modalCancelHandler);
$(".modal .btn-yes").off("click", modalConfirmHandler);
myModal.hide();
}
var modalConfirmHandler = function() {
// if accepted then we will now send input to R; setInputValue does not trigger shiny:inputchanged
setTimeout(
function() {
// since we need to communicate value to shiny
// evt.value = newvalue method will not work since using callbacks
// send the data directly to Shiny with sendInput
// which means shiny:inputchanged event will not be triggered
// preventing an infinite loop
Shiny.shinyapp.sendInput({"letterselector": letter.newvalue});
},
0
);
// clean up after ourselves and remove handlers
$(".modal .btn-no").off("click", modalCancelHandler);
$(".modal .btn-yes").off("click", modalConfirmHandler);
myModal.hide();
}
$(".modal .btn-no").on("click", modalCancelHandler);
$(".modal .btn-yes").on("click", modalConfirmHandler);
myModal.show();
})()
}
})
'
))
)
server <- function(input, output, session) {
observeEvent(input$letterselector, {
print(paste0("letter changed to ",input$letterselector))
})
}
shinyApp(ui = ui, server= server)