8  Building complex input instructions

As we saw earlier, inputs are objects used to convey information from the UI side to the server side.
This process is sending single values to a server through an input component.
For example, an actionButton will send an incremental integer, a textInput will send a character vector of length one.

While this is just fine in most cases, this can become a limitation when facing more complex situations.

Tip

The complex-instructions folder of the GitHub repository linked to this book implements examples to support the use cases in this chapter.

8.1 Use case

Let’s imagine the following use case:
Say you want to dynamically create multiple UI components based on a list of objects. A typical example would be cards1 created based on a data.frame. One card is created for each row of the table.
Now say you want each card to contain a button for the user to interact with the given row of the data.frame (to update its content or anything that involves this specific row).

8.2 First approach

The intuitive approach is to loop over the data.frame rows and create an actionButton with a specific InputId (because they need to be unique2), then listen to the corresponding input to trigger the expected action.

This is similar to having several inputs declared on UI side:

# -- button 1
actionButton(inputId = "button_1",
             label = "Increment count 1")

# -- button 2
actionButton(inputId = "button_2",
             label = "Increment count 2")

With the corresponding listeners on server side:

# -- button 1
observeEvent(input$button_1, 
             ...)

# -- button 2
observeEvent(input$button_2, 
             ...)

The problem is that this will create as many listeners as there are rows in the table, all of them with the same code except they will target a different row in the data.frame.

And if several actions are implemented on the same card (maybe update the element, delete it, show details, etc…) then this will end up in a huge number of listeners.

8.3 Optimized approach

When we think about the inputs that will be created by the first approach, the only difference between them is that input_1 will drive the action on the first object of the data.frame, input_2 on the second and so on.
An intuitive way to solve this is to think of an architecture with a single input access that would accept different values to target specific elements of the table.

But how can we keep the buttons with unique inputId values on UI side and a single entry in the input list on server side?

One solution for this is to create multiple button components and modify their behavior to hit a single input entry with different values using the onclick attribute.

# -- button 1
actionButton(inputId = "row_1",
             label = "Increment count 1",
             onclick = 'Shiny.setInputValue(\"increment\", this.id, {priority: \"event\"})')

# -- button 2
actionButton(inputId = "row_2",
             label = "Increment count 2",
             onclick = 'Shiny.setInputValue(\"increment\", this.id, {priority: \"event\"})')

The server side is improved with a single input listener:

# -- single listener
observeEvent(input$increment, {
  
  # -- get id from input value
  id <- unlist(strsplit(input$increment, "_"))[2]
  cat("Single input increment, id =", id, "\n")
  
  ...
  
})

Now both buttons hit the same listener on server side and it’s possible to trigger a contextual action from the cards. You still need to create one button per row (to get the unique inputId), but have a single listener on server side.

8.4 Heterogeneous input values

As long as you somehow decode the input value to determine which element is targeted by the action, one could imagine that this single access point could actually manage not only several elements, but also several actions.

Well, it’s possible to tune the inputId of the button to say encode a message:

# -- button 1
actionButton(inputId = "increment_1",
             label = "Increment count 1",
             onclick = 'Shiny.setInputValue(\"action\", this.id, {priority: \"event\"})')

# -- button 2
actionButton(inputId = "display_2",
             label = "Show count 2",
             onclick = 'Shiny.setInputValue(\"action\", this.id, {priority: \"event\"})')

And decode the message from the input value on server side:

# -- listener
observeEvent(input$action, {
  
  # -- get values from input
  action <- unlist(strsplit(input$action, "_"))
  names(action) <- c("type", "id")
  
  # -- action
  if(action['type'] == "increment")
    ...
  else
    ...
  
})

Now the buttons are not only targeting a single input on server side, but can also manage multiple actions.

8.5 Conclusion

This is a very powerful approach when it comes to dealing with a big amount of dynamically generated inputs. Of course, here and for demonstration purpose, the inputs where explicitly created on UI side. But it is very easy to generate those input components from server side. All you need to do is make sure they have a unique inputId and here the paste function is your friend (basically use paste to concatenate the action type with the element id to get a single input value).

Note that it is also possible to tune the setInputValue expression with sprintf based expressions and even replace this.id by an evaluated expression.

Warning

As long as you pass an explicit message to an input, this value will be accessible from the front-end browser for example using the inspect feature. This means no critical / confidential data should be exposed here!

browser console

  1. {bslib} implements Bootstrap cards.↩︎

  2. If not, the browser console will raise a warning.↩︎