Background
I am currently building a Shiny application that converts tabular ecological field data into a well-defined exchange format in XML. Part of the UI consists of an input form, where users can map columns of their uploaded data to specific elements of the exchange format. The challenge is that the number of mappings – and thus the number of required input fields – depends on the user data. Ideally, users would be able to dynamically add and remove UI elements, while the server collects all valid input values from the UI. While working on that problem it occurred to me that this functionality might be quite useful in a number of circumstances (this open issue on GitHub confirmed my intuition), so I decided to write a blog post about it.
An example app
The key idea is to use a Shiny module
to UI components and collect the input values in a reactive data.frame
. I’ll demonstrate the approach using a simple movie-themed app, where users can enter the title, director and release year of their favorite movies. Here’s a quick preview of it.
Main UI and server function
Let’s set up a basic code skeleton for a single-file app with an additional module server function.
library(shiny)
ui = fluidPage(
# Main app UI code
)
server = function(input, output) {
# Main app Server code
}
add_formGroup = function(id){
moduleServer(id, function(input, output, session){
# Module server Code
})
}
shinyApp(ui = ui, server = server)
As we have the same three inputs (title, director, year) for every movie, we organize the input mask like a table and let users add new rows, i.e. movies, as needed. For now, we only need the table headers because the input fields will be generated by the add_formGroup
module. Below the table headers, we place an empty div
that serves as a fixed placeholder for the insertion of new rows. The last element in the UI is an actionButton
that will tell the server to render a new row of inputs and add it to the UI.
ui = fluidPage(
titlePanel("My favorite movies"),
fluidRow(
column(4, tags$label("Title")),
column(4, tags$label("Director")),
column(3, tags$label("Year released"))
),
div(id = "placeholder"),
actionButton(inputId = "new_row", label = "Add new movie", icon = icon("plus"))
)
In the main server function, we define a reactiveVal
to store user inputs in a data.frame
. We’ll aptly name that reactive variable user_inputs
. We then use the observeEvent()
function to specify what should happen when the user presses the new_row
button. That’s essentially three things. First, generate a new UI component with the add_formGroup
module. We’ll look at the specifics of that module in a second, for now just note that the module server function expects a unique id
and the user_data
reactive variable as arguments and it returns a container with rendered UI elements. Second, the UI container is injected into the main UI right before the placeholder div
using the insertUI()
function. Finally, the row_id
variable is incremented to have an id ready the next time the new_row
button is pressed.
server = function(input, output {
user_inputs = reactiveVal(data.frame(title = character(0),
director = character(0),
year = integer(0)))
row_id = 1
observeEvent(
eventExpr = input$new_row,
handlerExpr = {
new_row = add_formGroup(paste0("row", row_id), user_inputs)
insertUI(new_row, selector = "#placeholder", where = "beforeBegin")
row_id <<- row_id + 1
}
)
}
A Shiny module as component factory
We made sure to always call the module server function with a unique id. The reason is that moduleServer()
creates a namespace from the supplied id by prefixing all objects in input
, output
and session
with it. Since this happens implicitly, the server code in a module generally looks no different from the server code in the main app. For example, if we call the add_formGroup
server function with id = "row1"
, we can use standard syntax (input$title
) to read the value from the title
input field, while the server will look for an input field named row1-title
. This simple trick allows for a clean separation of different components of an application and can be used to dynamically generate UI elements and server logic. There’s one caveat. While namespacing in modules is implicit for the input
, output
and session
objects, it needs to be added explicitly to UI elements. We do this by wrapping the ids of UI elements in a namespacing function generated by Shiny’s NS()
. This ensures that module server and module UI are properly linked and encapsulated from the rest of the application.
Two additional observers are needed to make the app work. The first observer monitors the three textInputs
and updates the reactive variable user_inputs
if any input changes. In order to make user_inputs
accessible within the module, we passed it as a parameter when calling the module from the main server. The second observer monitors the actionButton
and removes the entire module-specific UI from the main UI. This is done by wrapping the UI elements in a div
and removing the entire div upon pressing the remove_entry
button.
add_formGroup = function(id, user_inputs){
moduleServer(id, function(input, output, session){
ns = NS(id)
# Build UI
ui = div(
id = id,
fluidRow(
column(4, textInput(ns("title"), label = NULL, width = "100%")),
column(4, textInput(ns("director"), label = NULL, width = "100%")),
column(3, textInput(ns("year"), label = NULL, width = "100%")),
column(1, actionButton(inputId = ns("remove_entry"), label = "X", style = "margin: 0px; padding: 0px; width: 34px; height: 34px"))
)
)
# Observer for textInputs --> update 'user_inputs' reactiveVal
observeEvent(
eventExpr = {
input$title
input$director
input$year
},
handlerExpr = {
new_row = c(title = input$title, director = input$director, year = input$year)
inputs_df = isolate(user_inputs())
inputs_df[id,] = new_row
user_inputs(inputs_df)
}
)
# Observer for 'remove_entry' --> remove UI
observeEvent(
eventExpr = input$remove_entry,
handlerExpr = {
inputs_df = isolate(user_inputs())
user_inputs(inputs_df[rownames(inputs_df) != id,])
removeUI(selector = paste0("#", id), session = session)
}
)
return(ui)
})
}
Finally, let’s confirm that the app works as intended by displaying the current state of the user_inputs
reactive variable. We render the content of user_inputs
(a data.frame
) with the renderTable()
function:
output$user_inputs = renderTable(user_inputs()) # Server code
and output the rendered data.frame
in the main UI.
tableOutput("user_inputs") # UI code
That’s it - adding and removing UI elements with Shiny modules is that simple.
Summary
Shiny modules provide a powerful abstraction when developing complex dynamic web applications. The implicit namespacing within modules elegantly solves the problem of name collisions and allows building reusable components for web applications. Just remember that all the communication between module and the main app happens via reactive variables that are passed as arguments to the module’s server function.
For the record, here is a gist of the full app to experiment and play around with.
comments powered by Disqus