Using Shiny modules to build reusable UI components

Posted on Sunday, June 19, 2022

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