Intoduction to Shiny

Satellite
Visualization
Dashboard
Learn how to work and customise simple {shiny} apps

Objectives

  • Understand the basic structure of a Shiny App
  • Customise the UI by adding new input
  • Understand the app server and reactivity
  • Add a new output to an existing shiny app

Libraries

During this session we will be using some specific R packages, please make sure they are installed and loaded

# install.packages("shiny")
# install.packages("bslib")
library(shiny)
library(bslib)
library(here)
library(tidyverse)

Setting up your project

Project structure

If not done already, download and unzip the course folder. Save the uncompressed folder to a location that is not connected to OneDrive and navigate into it.

This folder gives an example of a typical, single-file shiny app structure:

  • 📁 data
    • 📁 clean
      • 📄 moissala_data.rds
  • 📄 app.R

It creates a small epidemiological dashboard that analyses simulated data from a measles outbreak in Moïssala, Southern Chad (moissala_data.rds). This folder will be your working directory for the entire session.

Definitions

In this session we will use interchangeably dashboard and app as both refer to the same interactive web application built with the {shiny} package. App is the technical term for any application build with the package, while dashboard emphasizes the visual presentation of data and metrics within that app.

Exploring the project

This project is a very simple shiny app, and we are first going to understand how it is organised.

Have a look at your project:

  • can you see where the data are stored? under which format?

  • open the file app.R and explore it

As any other project, our app has some data stored internally in data/clean folders, nothing fancy about that. Now the core of it lies in the app.R file.

Anatomy of a Shiny App

Every Shiny app has four essential components:

  1. User Interface (UI): defines the layout and appearance of your app
  2. Server: contains the logic that creates outputs and handles user interactions
  3. Reactivity: through reactive expressions, this links the user (inputs) with the server logic and the visual outputs
  4. App call: launches the application

In simple apps, these are all bundled into a single file app.R, but they are can be organised in a two-file structure with server.R and ui.R for more complex apps.

Key structure and components of a simple shiny app

Looking at app.R, can you identify these three components?

  • Where does the UI start and end?
  • Where does the server logic live?
  • Can you spot where reactivity may lie ?
  • What line of code actually runs the app?

The basic structure looks like this:

library(shiny)
library(bslib)

ui <- page_fluid(
  # UI elements go here
)

server <- function(input, output, session) {
  # Server logic goes here
}

shinyApp(ui = ui, server = server)

Where two objects: ui and server (a function) are defined, and then passed to the shinyApp() function.

Running Your First App

Let’s run the app to see it in action:

  1. Open app.R
  2. Click the Run App button at the top of the script (or use Cmd/Ctrl + A + Enter)
  3. The app should open in a new window or in the Viewer pane

Once the app is running, try interacting with any input controls:

  • Do the outputs update when you change inputs?
  • Click the Stop button (red square) to close the app when done

If the app doesn’t run, check the R console for error messages

Now let’s dig into it and customise it

Understanding the UI

The User Interface (UI) is where we design the appearance and layout of the app, we will define which inputs can user interact with, choose where to put the outputs (plot, tables) of the analyses and organise everything on the screen. It is built using layout, input and ouput functions that nest inside each other.

Why {bslib}?

Modern Shiny apps use the {bslib} package for layouts instead of the older base Shiny functions. {bslib} provides:

  • Modern Bootstrap CSS: Uses the latest Bootstrap framework for better styling and responsive design
  • Theming capabilities: Easy customization of app appearance with custom themes
  • Better layouts: More intuitive and flexible layout functions
  • Recommended by Posit: The Shiny development team recommends {bslib} for all new apps

While you may see older Shiny code using functions like fluidPage() and sidebarLayout(), we’ll focus on the modern {bslib} equivalents throughout this tutorial.

Layout Functions

Layout functions control how your app is structured visually. The most common {bslib} pattern is:

  • page_sidebar(): Creates a page with a built-in sidebar layout:
    • Takes a title argument for the app title
    • Contains a sidebar() function that defines the sidebar panel (typically for input controls)
    • Main content (outputs) goes directly in the page, no wrapper needed
  • page_fluid(): Creates a responsive page without a sidebar
  • page_fillable(): Creates a page that fills the browser height - great for full-screen dashboards

Other useful layout functions include layout_columns() and layout_column_wrap() for creating custom grid layouts, and navset_tab() for organizing content into tabs.

Input Functions

Input functions create interactive controls that users can manipulate. Each input has two key arguments:

  1. inputId: A unique identifier (string) used to access the value in the server
  2. label: Text displayed to the user describing the input
  3. any other arguments: depending on which input function is called

Common input functions include:

  • dateRangeInput(): Date range picker with start and end dates
  • selectInput(): Dropdown menu with multiple options
  • numericInput(): Input box for numeric values
  • radioButtons(): Radio buttons for mutually exclusive choices
Tip

The inputId must be unique across your entire app. You’ll reference it in the server function as input$date_range, input$variable, etc.

There are a loads of input possibilities, and they can be explored here: Shiny Widget Gallery

Output Functions

Output functions create placeholders in the UI where results will be displayed. Like inputs, each output has a unique ID, and the main ones are:

  • plotOutput(): Placeholder for plots
  • tableOutput(): Placeholder for data tables

But these are just placeholders - the actual content is generated in the server function using matching render*() functions. We will deal with this later

In the app.R file:

  • What is the main layout function being used? (look for a function with layout in the name)
  • Can you identify the title of the app?
  • How many input controls are there? What are their inputIds?
  • How many output placeholders are there? What are their outputIds and types?
  • Try to trace the connection: for each output in the UI, can you find where it’s rendered in the server?

Looking at the app.R file, we can identify the key input and output elements that make this app interactive.

  1. There is one input control: a dateRangeInput() with inputId = "date_range" that allows users to select a start and end date for filtering the data. This input appears in the sidebar panel and is initialized with the minimum and maximum dates from the dataset.

  2. There is one output placeholder: a plotOutput() with outputId = "epicurve" that displays the epidemic curve in the main panel.

  3. The connection between the UI and server is established through these IDs in the server function: you’ll see input$date_range being used to access the selected dates, and output$epicurve being assigned with renderPlot() to generate the actual plot. This pairing of dateRangeInput() with renderPlot() demonstrates the fundamental pattern of Shiny: inputs capture user choices, outputs display results, and the server connects them through reactive expressions.

Adding a New Input

Now that we understand how inputs and outputs work together, let’s add a new input to customize our epicurve.

Can you think of a new input that would improve the epicurve? What additional control might users want?

An epicurve showing daily cases is detailed, but it can be visually overwhelming, especially over long time periods. What if we added an input that allows users to select the level of time aggregation (day/week/month/year)? This would let them zoom in for detailed daily trends or zoom out to see broader patterns.

There are several ways to do this, we will stick to an easy one, and achieve it by creating a select input with selectInput(). This function creates a dropdown menu where users can choose from predefined options. The basic syntax is:

selectInput(
  inputId = "time_unit",      # Unique ID to reference in server
  label = "Time Unit:",        # Label shown to user
  choices = c("Day", "Week", "Month", "Year"),  # Options in dropdown
  selected = "Day"             # Default selection
)

Add this selectInput() to the sidebar panel in your UI, right below the dateRangeInput().

Once this is done, run the app again, what happens when you interact with your new input ?

Nice you managed to add a new input, but this is not yet active as it is not linked to our data. Now is a good occasion to dive into the server side of things !

Understanding the Server

The server function is where the reactive magic happens. It takes user inputs and creates outputs dynamically based on user interactions.

Server Structure

The server is always defined as a function with three arguments and the following pattern:

server <- function(input, output, session) {
  # Server logic goes here

  # Create outputs
  output$plot1 <- renderPlot({
    # Code to create a plot
  })
  
  output$table1 <- renderTable({
    # Code to create a table
  })

}
  • input: A list-like object containing all input values from the UI (accessed as input$inputId)
  • output: A list-like object where you assign rendered outputs (accessed as output$outputId)
  • session: Contains information about the current Shiny session (advanced usage)

Data management in the server

The core of the server function is where we manipulate data based on user inputs. This is where we:

  • Filter data based on user selections
  • Transform variables
  • Calculate statistics
  • Prepare data for visualization

This all happens before we create the visual outputs. Think of it as a pipeline:

user inputsdata manipulationoutputs

For example, if a user selects a date range, we need to filter our dataset to only include cases within that range. If they want to change the time aggregation, we need to transform the dates accordingly. The server handles all this logic.

In the server function of app.R:

  • Can you find where the data is been loaded ? Where is it compared to the server and ui ?
  • Can you find where data is being filtered?
  • Which input values are being used in the filtering? (look for input$...)
  • Where does the filtering happen relative to creating the plot?

Notice that in app.R, the data is loaded outside the server function (and outside the UI as well). This way, it’s loaded once when the app starts, not every time a user interacts with it. This is much more efficient than loading data inside the server, which would reload it repeatedly.

Data are then used in the server where they are filtered based on the date range (input$date_range) defined by the user, thus creating a new, filtered dataset: filtered_data.

# filter the data based on user input
  filtered_data <- reactive({
    linelist |>
      filter(
        date_onset >= input$date_range[1], # the lower bound of the date range is accessed here
        date_onset <= input$date_range[2] # the upper bound of the date range is accessed here
      )
  })

Now meticulous observer will notice that our filtering step is wrapped into a reactive({}) call - here is a very important concept, this is what makes this all process interactive !

Reactivity: The Heart of Shiny

Reactivity is what makes Shiny apps interactive. When a user changes an input, Shiny automatically knows which outputs depend on that input and re-runs only the necessary code.

The basic reactive flow looks like this:

User changes inputReactive expression updatesOutput re-renders

Reactive Expressions

In our app.R, we use reactive() to create a filtered version of the data based on the date range selected by the user:

filtered_data <- reactive({
  linelist |>
    filter(
      date_onset >= input$date_range[1], # lower bound of date range
      date_onset <= input$date_range[2]  # upper bound of date range
    )
})

This creates a reactive expression that:

  1. Automatically re-runs when input$date_range changes
  2. Caches its result so it doesn’t re-compute unnecessarily
  3. Can be used by multiple outputs efficiently
  4. Is called with parentheses: filtered_data() (like a function)

With this reactive expression our data management step is automatically linked to all of our user inputs, and can be automatically updated when they change !

Tip

Reactive expressions are called with () because they are special functions. Always use filtered_data(), not filtered_data, when you want to access the filtered data.

In the server function:

  • Can you identify the reactive() expressions? How many do you see ?
  • What input value triggers them to update?
  • Where is filtered_data() being used? (remember the parentheses!)
  • What would happen if the user changes the date range?

The beauty of reactivity is that you don’t need to manually tell Shiny when to update outputs. Shiny automatically tracks dependencies:

when input$date_range changes → filtered_data() updates → any output using filtered_data() re-renders.

Implement our new input

Remember the selectInput() we added for time aggregation? Now we need to make it functional in the server by modifying our reactive expression. You noticed earlier that we are indeed working with two reactive expression

  1. filtered_date() which then feeds in
  2. plot_df() to create a dataframe used for plotting the epicurve.
  • How are data been processed for plotting ?
  • Do we need to change anything if we want to aggregate ?

Currently, our epicurve counts cases by exact date using the reactive expression filtered_data():

filtered_data() |>
  count(date_onset)

But we want to aggregate by the time unit selected by the user (input$time_unit). So we need to transform date_onset based on the selected time unit before counting, and this can be implemented in our plot_df() reactive expression.

Tip

You can use floor_date() from the {lubridate} package to round dates to different units. The syntax is:

floor_date(date_column, unit = "week")  # unit can be "day", "week", "month", "year"

Note that floor_date() expects lowercase units (“week”, “month”), but our selectInput() choices are capitalized (“Week”, “Month”). You’ll need to convert them using tolower().

Important

Notice that we use two separate reactive expressions for data manipulation:

  1. filtered_data(): Returns the filtered linelist with all individual cases
  2. plot_df(): Returns an aggregated count dataframe

This separation is intentional. By keeping filtered_data() in its original linelist format (one row per case), we maintain flexibility to add other outputs later (like tables, summary statistics, or additional plots) that might need access to individual case data. The plot_df() reactive then handles the specific aggregation needed for the epicurve visualization.

Modify the plot_df() reactive expression to:

  1. Create a new variable that floors date_onset to the selected time unit (use mutate() and floor_date)
  2. Count by this new aggregated date variable instead of date_onset

Then make necessary changes so that the plotting function use newly aggregated date on the x-axis

Once you’ve implemented this, test your app:

  • Change the time unit dropdown - does the epicurve update automatically?
  • Try “Week” - do you see weekly aggregated bars?
  • Try “Month” - does it show monthly counts?
  • Combine it with the date range filter - does everything work together?

This demonstrates the power of reactivity: you modified the render function to use input$time_unit, and Shiny automatically knows to re-run it when either input$time_unit OR input$date_range changes because they are linked through reactive expressions !

Render Functions

Now that we understand how data flows through reactive expressions, we need to display it to the user (even though you just made it work !). This is where render functions come in.

Render functions are the final step in our reactive pipeline. They take the processed data and create visual outputs (plots, tables, text) that appear in the UI.

The Render Pattern

Each type of output placeholder in the UI (see Output Functions) has a corresponding render function in the server:

UI Function Server Function What it creates
plotOutput() renderPlot() Plots and visualizations
tableOutput() renderTable() Data tables
textOutput() renderText() Plain text
verbatimTextOutput() renderPrint() Console-style output

These are the base Shiny render functions, but many visualization and table packages provide their own specialized render functions. For example, {leaflet} has renderLeaflet() for interactive maps, and {highcharter} has renderHighchart() for interactive charts. These package-specific render functions follow the same pattern but are optimized for their respective output types.

How Render Functions Work

Render functions are reactive endpoints. They:

  1. Automatically re-execute when their reactive dependencies change
  2. Send the updated output to the UI
  3. Are always assigned to output$ with a name that matches an outputId in the UI

For example, if we have plotOutput("epicurve") in the UI, we need output$epicurve <- renderPlot({...}) in the server.

In the server function:

  • How many output$... assignments are there?
  • Do the output names match the outputIds in the UI?
  • What type of render function is being used?
  • Can you identify the reactive dependencies? (What inputs or reactive expressions does it use?)

In our app, we have one render function that creates the epicurve:

output$epicurve <- renderPlot({
  plot_df() |> 
    ggplot(aes(x = date_onset, y = n)) +
    geom_col(fill = "steelblue") +
    labs(
      title = "Cases by Date of Onset",
      x = "Date of Onset",
      y = "Number of Cases"
    ) +
    theme_minimal()
})

Notice how this render function depends on plot_df(), which itself depends on filtered_data(). When the user changes the date range OR the time units, filtered_data() updates, which updates plot_df(), which automatically triggers renderPlot() to re-run and update the plot in the UI.

Inside a render function, you write normal R code to create your output. For renderPlot(), this is typically {ggplot2} code. For renderTable(), you’d prepare a dataframe. The key is that the last line of the render function should produce the output you want to display. This

Inside the render function for our epicurve, can you make the following changes to improve the visualization:

  1. Change the fill color of the bars to something more appropriate for disease surveillance (hint: try a red tone like "#E74C3C" or "coral")

  2. Add a border to the bars using the color argument in geom_col() (try color = "white" to separate bars clearly)

Bonus:

  1. Update the plot title to be more informative - include the time unit being displayed (hint: you can use paste() or glue() to combine text with input$time_unit)

  2. Improve the axis labels:

    • Make the x-axis label dynamic based on the selected time unit
    • Consider adding units to the y-axis (e.g., “Number of Cases (n)”)
  3. Add a subtitle that shows the date range being displayed using subtitle in labs()

  4. Add a caption that says how many cases with valid dates are been displayed

The customization possibilities within render functions are as infinite as the customization of plots themselves: there’s no right or wrong way to style your outputs, only what best communicates your data (except pie charts—because nothing says “I understand data visualization” quite like a circle divided into 9 differently colored slices that all look identical).

Here is a suggestion:

renderPlot({
    n_valid <- nrow(filtered_data() |> filter(!is.na(date_onset)))

    plot_df() |>
      ggplot(aes(x = agg_date, y = n)) +
      geom_col(fill = "#E74C3C", color = "white", linewidth = 0.3) +
      labs(
        title = paste("Cases by", input$time_unit),
        subtitle = paste(
          "Date range:",
          format(input$date_range[1], "%b %d, %Y"),
          "to",
          format(input$date_range[2], "%b %d, %Y")
        ),
        x = paste("Date of Onset (", input$time_unit, ")", sep = ""),
        y = "Number of Cases (n)",
        caption = paste(
          "Displaying",
          n_valid,
          "cases with valid dates"
        )
      ) +
      theme_minimal()
  })
}

Now that we’ve explored all three components of a Shiny app: the UI (defining inputs and output placeholders), reactive expressions (managing and filtering data based on user inputs), and render functions (creating the actual visualizations), you have all the building blocks needed to add new features.

Let’s put this knowledge into practice by adding a completely new output to our dashboard!

Adding a new output

Now that you’ve built and customized your first Shiny app, let’s pause and think about dashboard design more broadly. Before we add another output, we should ask ourselves: what makes a dashboard useful?

A good dashboard isn’t just about cramming in every possible visualization—it’s about communicating insights effectively and enabling decision-making. Before adding any new element, ask yourself:

  1. Purpose: What question does this output answer? Who is the intended user?
  2. Clarity: Does this visualization communicate information clearly, or does it add noise?
  3. Actionability: Can users make decisions or take actions based on what they see?
  4. Context: Does this output complement existing visualizations or duplicate information?
Important

Every element should serve a purpose. If you can’t articulate why something is there, it probably shouldn’t be.

Think about our current dashboard:

  • Who is it designed for?
  • What questions can it answer?
  • What questions can’t it answer that might be important?
  • If you were responding to this outbreak, what would you want to see next?

Planning Your Next Output

Rather than randomly adding features, let’s be intentional about what we add. Any epidemiologist in the room will tell you that our dashboard currently lacks Person and Place components to complement the Time analysis from the epicurve (which could benefit from much improvement let’s be honest, but we are short on time). Maps are another level in R, so let’s stick with something simpler and focus on the Person analysis.

For a person analysis in outbreak investigation, we typically want to understand:

  • Age distribution: Who is being affected? Are certain age groups more vulnerable?
  • Gender distribution: Are there gender-specific patterns?
  • Case demographics: What are the key characteristics of cases?

We’re going to add a comprehensive person analysis section that includes:

  1. An age pyramid showing the distribution of cases by age group and gender
  2. A summary table displaying key demographic statistics

Ok in order to implement these new outputs, we need to deal with two things which you know now:

  1. the UI: to keep our dashboard organized we’ll use tabs to separate the _Summary Statistics from the Age Pyramid plot.
  2. the server: We need to code the logic to generate a summary table and an age pyramid.

The UI

Understanding Tabsets

Before we start coding, let’s understand how to organize multiple outputs using tabs. Tabsets allow you to place related visualizations in separate panels that users can switch between, keeping the interface clean and organized.

In {bslib}, you create tabs using:

  • navset_tab(): Creates the container for tabs
  • nav_panel(): Defines each individual tab with a title and content

The basic structure looks like:

navset_tab(
  nav_panel("Tab 1 Title", 
            plotOutput("plot1")),
  nav_panel("Tab 2 Title", 
            tableOutput("table1"))
)

Each nav_panel() can contain multiple outputs (plots, tables, text).

Step 1: Restructure the UI with tabsets

Modify your page_sidebar() in the UI to use a tabset structure:

  1. Below the epicurve, add a new section with a navset_tab() for the Person Analysis
  2. Create two tabs for the Person Analysis:
    • First tab: “Age Pyramid” containing a placeholder for the age pyramid plotOutput("age_pyramid")
    • Second tab: “Summary Statistics” containing a placeholder for the table tableOutput("summary_table")

Run your app to see the tabbed interface.

  • Does your epicurve still appear at the top?
  • Do you see the two tabs for Person Analysis below it?

Ok now your page_sidebar() should look like:

# Inside page_sidebar(), after the epicurve
plotOutput("epicurve"),

h3("Person Analysis"), 

navset_tab(
  nav_panel("Age Pyramid",
            plotOutput("age_pyramid")),
  nav_panel("Summary Statistics",
            tableOutput("summary_table"))
)

Let’s not complicate things further for now and not add anymore inputs - no that our UI is set up we can implement the server logic.

The server

Adding the Age Pyramid

An age pyramid is a powerful visualization for understanding the demographic structure of cases. It shows the distribution of cases by age group, split by gender, with males on one side and females on the other. To create an age pyramid, we need to:

  • Count cases by age group and gender (age_group adn `` in the linelist)
  • Create a horizontal bar chart with males and females on opposite sides

To simplify this process, and focuss on the learning of {shiny} concepts, we will use the package {apyramid} to build the Age pyramid plot

apyramid::age_pyramid(
  data = our_data,
  age_group = "age_group",
  split_by = "sex"
)

Step 2: Conceptualise the age pyramid

In the server function, thing carefully:

  • What dataset do you want to use for the age pyramid ?
  • Do you want the dataset to by a reactive expression that responds to user inputs ?
  • Do you want / need to create a new reactive expression for this plot ?

We want our age pyramid to be influenced by our time_unit and date_range inputs the same way that our epicurve is to make sure they display the same information. Thus, we want to work with the reactive expression filtered_data() to maintain consistency.

Now we need to perform some data manipulation prior to plotting (remove NAs), but these are not dependent on any user inputs, and could be implemented right before plotting. However, to maintain flexibility in our dashboard, and for a future input that could allow the user to interact with the age pyramid (also to practice !), we are going to create another reactive expression called pyramid_df() prior to the plot

Step 3: Create the age pyramid data

In the server function, after the epicurve render function:

  • Create a reactive expression called pyramid_df that removes NAs for age_group and sex
  • How are you going to access this reactive expression ?

Step 4: Call the age pyramid render function

Right after defining pyramid_df(),

  • create a render function to plot the age pyramid
Tip

Remember, you need to assign the renderPlot() function to an output$ of a corresponding ID that you have defined in a placeholder in the UI

  • In this render function add the call to apyramid to create the function but be careful with the data you are using !
# this will egenrate an epicurve
apyramid::age_pyramid(
  data = ______,
  age_group = "age_group",
  split_by = "sex"
)

when you are confident that all is in order, run your app and navigate to the age pyramid tab. Do you see the age pyramid?

Bonus You will notice that the sex variable has very simple labels,

  • How and where in the server would you recode these ?

Nice, great to see some more plot up and running, let’s deal with our final task: adding a demography table to this other tab !

Adding a summary table

Here are task is fairly straightforward, we will use our filtered data (via the reactive expression filtered_data()) and summarise() it to display some summary statistics by age group - we will do this directly in the render function renderTable()

Step 5: Create the summary table

In the server function, after the age pyramid render function:

  • add a render function to display the summary table
Tip

Remember, you need to assign the render function to an output of an ID that you defined in a placeholder in the UI

  • in this render function make a summary table of filtered_data() wich shows by age_group:

    • Total Cases
    • Male Cases = sum(gender == “m”, na.rm = TRUE),
    • Female Cases = sum(gender == “f”, na.rm = TRUE),

Bonus - Consider adding more useful statistics: - Case fatality ratio (using the outcome data) - Proportion by age group

This is start to look professional ! Now that you have multiple outputs, you could spend hours making sure that everything looks nice, consistent and cohesive - unfortunately, we will not do this now as we do not have infinity ahead of use.

Done!

And this is a clap, you are done ! Congrats on building and tweaking your first shiny app (if it was) ! I hope you have enjoyed this small practical, and wish you the best of luck in the depth of dashboarding in R. You can find below a link to a solution script for the final App (hosted on github).

Going Further

Shiny is a huge field within the R community and there are a lots of amazing resources available online - here is a non-exhaustive list

R packages

  • {epishiny} - provides simple functions that produce engaging, feature-rich interactive visualisations and dashboards from epidemiological data using R’s shiny. (developped by Epicentre Data Science Team)
  • {shinyWidgets} - gives you many many more widgets that can be used in your app
  • {highcharter} - R wrapper for the Highcharts (javascript) library for interactive visualisations (license required, potentially paid)