4 min read

Helpful JavaScript Functions for Shiny

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)