ETC5523: Communicating with Data

Workshop 9: Build a Shiny app from scratch

Author

Michael Lydeamore

Published

1 September 2001

🎯 Objectives

By the end of this workshop, you will be able to:

  • create a complete Shiny app in a new app.R file;
  • connect input controls to reactive outputs;
  • use a reactive expression to share filtered data; and
  • improve an app with purposeful labels, guidance and source information.
What you will build

You will build an app that lets a cricket follower choose a Women’s Premier League season and explore how leading batters score their runs.

Start with a blank app.R. Build and run each stage before moving to the next one.

1. Prepare the app and data (4 minutes)

  1. Create a new folder called wpl-app.
  2. Inside that folder, create a blank file called app.R.
  3. Download wpl_batters.csv and save it in the same folder.
  4. Add code to load these packages:
    • shiny
    • bslib
    • dplyr
    • ggplot2
  5. Read the CSV into an object called wpldata.

Run the first few lines and inspect the data with glimpse(wpldata).

Note

Each row describes one batter in one season. The variables include runs, balls faced, dot-ball percentage and boundary percentage. The data contain the 20 leading run-scorers in each season who faced at least 50 balls.

Check your setup

library(shiny)
library(bslib)
library(dplyr)
library(ggplot2)

wpldata <- read.csv("wpl_batters.csv")

glimpse(wpldata)

2. Create the smallest possible app (4 minutes)

A Shiny app needs a UI, a server function and a call to shinyApp().

Write these three components in app.R:

ui <- page_fluid(
  "My first Shiny app"
)

server <- function(input, output, session) {
}

shinyApp(ui, server)

Run the app. You should see the text My first Shiny app in a browser or viewer window.

Tip

Run the app after every small change. When something breaks, the most recent change is the first place to look.

3. Give the user a choice (5 minutes)

Replace page_fluid() with page_sidebar() and add a sidebar containing a selectInput().

Your input should:

  • have the ID "season";
  • use the label Choose a season; and
  • take its choices from wpldata$season.

Run the app and change the selected season. Nothing else should happen yet.

One input in the UI

ui <- page_sidebar(
  title = "WPL batting explorer",
  sidebar = sidebar(
    selectInput(
      "season",
      "Choose a season",
      choices = sort(unique(wpldata$season))
    )
  )
)

The string "season" creates the input ID. Its current value will be available in the server as input$season.

4. Reserve a place for the plot (3 minutes)

Add this UI function after the sidebar:

plotOutput("batters_plot")

Answer these questions before continuing:

  1. Does plotOutput() draw a plot?
  2. What output ID have you created?
  3. What will that ID look like inside the server function?

Connecting IDs

plotOutput() creates a place in the UI; it does not draw the plot.

The output ID is "batters_plot". In the server, it becomes:

output$batters_plot

5. Connect the input to the output (8 minutes)

Inside server(), assign renderPlot() to output$batters_plot.

The plotting code should:

  1. filter wpldata to season == input$season;
  2. map dot_percent to the x-axis;
  3. map boundary_percent to the y-axis; and
  4. draw the observations with geom_point().

Run the app and confirm that the plot changes when you select another season.

Note

Trace the reactive path:

selectInput("season", ...) → input$season → renderPlot() → plotOutput("batters_plot")

The first working server

server <- function(input, output, session) {
  output$batters_plot <- renderPlot({
    wpldata |>
      filter(season == input$season) |>
      ggplot(aes(x = dot_percent, y = boundary_percent)) +
      geom_point()
  })
}

The UI and server use the same output ID. renderPlot() reruns whenever input$season changes.

6. Add a second input (7 minutes)

Let the user choose how many leading run-scorers to display.

  1. Add a sliderInput() with the ID "top_n".
  2. Let the user choose from 5 to 20 batters, with a default of 10.
  3. After filtering by season, use slice_max() to keep input$top_n batters ranked by runs.
  4. Map balls_faced to point size.

Before running the app, predict which expression will rerun when the slider moves.

A second input

Add this inside sidebar():

sliderInput(
  "top_n",
  "Number of leading batters",
  min = 5,
  max = 20,
  value = 10,
  step = 1
)

Update the plot:

output$batters_plot <- renderPlot({
  wpldata |>
    filter(season == input$season) |>
    slice_max(runs, n = input$top_n) |>
    ggplot(aes(
      x = dot_percent,
      y = boundary_percent,
      size = balls_faced
    )) +
    geom_point()
})

7. Create and reuse reactive data (7 minutes)

Move the filtering and ranking into a reactive expression called selected_batters.

Then:

  • use selected_batters() inside renderPlot(); and
  • create a dynamic plot title with renderText() and textOutput().

The title should include the selected season and number of batters.

Shared reactive data

Inside server():

selected_batters <- reactive({
  wpldata |>
    filter(season == input$season) |>
    slice_max(runs, n = input$top_n)
})

output$plot_title <- renderText({
  paste(
    "Batting patterns for the leading",
    input$top_n,
    "batters in",
    input$season
  )
})

output$batters_plot <- renderPlot({
  ggplot(
    selected_batters(),
    aes(dot_percent, boundary_percent, size = balls_faced)
  ) +
    geom_point()
})

Add the matching UI function above the plot:

textOutput("plot_title")

Remember the parentheses in selected_batters().

8. Make the app communicate (7 minutes)

Improve the app for someone who did not write it.

Add:

  • an informative app title;
  • percentage labels on both axes;
  • a sentence explaining how to read the chart;
  • a note explaining which batters are included; and
  • a linked source for the data.

Do not add another feature until a reader can understand the current output.

One possible UI

ui <- page_sidebar(
  title = "How do WPL batters score their runs?",
  theme = bs_theme(preset = "shiny", primary = "#006DAE"),
  sidebar = sidebar(
    selectInput(
      "season", "Choose a season",
      choices = sort(unique(wpldata$season))
    ),
    sliderInput(
      "top_n", "Number of leading batters",
      min = 5, max = 20, value = 10
    )
  ),
  card(
    card_header(textOutput("plot_title")),
    p("Batters towards the upper-left combine more boundaries with fewer dot balls."),
    plotOutput("batters_plot"),
    card_footer(
      markdown(
        "Players faced at least 50 balls. Data: [Cricsheet](https://cricsheet.org/)."
      )
    )
  )
)

Add these layers to the plot:

labs(
  x = "Dot-ball percentage",
  y = "Boundary percentage",
  size = "Balls faced"
) +
scale_x_continuous(labels = scales::label_percent(scale = 1)) +
scale_y_continuous(labels = scales::label_percent(scale = 1)) +
theme_minimal(base_size = 15)

9. Test another student’s app (5 minutes)

Swap with a partner. Do not explain your app before they use it.

The tester should:

  1. select another season;
  2. change the number of batters;
  3. explain what the upper-left of the chart means; and
  4. identify the data scope and source.

The author should record one programming problem and one communication problem, then fix the more consequential one.

Extension: choose one purposeful addition

If your app is working, add one feature that supports a clear user question:

  • colour points by batting_team;
  • label the three highest run-scorers;
  • add a table made with renderTable() and tableOutput(); or
  • add hover details with Plotly.

Before coding, complete:

This feature helps the user answer …

Complete solution

library(shiny)
library(bslib)
library(dplyr)
library(ggplot2)

wpldata <- read.csv("wpl_batters.csv")

ui <- page_sidebar(
  title = "How do WPL batters score their runs?",
  theme = bs_theme(preset = "shiny", primary = "#006DAE"),
  sidebar = sidebar(
    selectInput(
      "season",
      "Choose a season",
      choices = sort(unique(wpldata$season))
    ),
    sliderInput(
      "top_n",
      "Number of leading batters",
      min = 5,
      max = 20,
      value = 10,
      step = 1
    )
  ),
  card(
    card_header(textOutput("plot_title")),
    p("Batters towards the upper-left combine more boundaries with fewer dot balls."),
    plotOutput("batters_plot"),
    card_footer(
      markdown(
        "Players faced at least 50 balls. Data: [Cricsheet](https://cricsheet.org/)."
      )
    )
  )
)

server <- function(input, output, session) {
  selected_batters <- reactive({
    wpldata |>
      filter(season == input$season) |>
      slice_max(runs, n = input$top_n)
  })

  output$plot_title <- renderText({
    paste(
      "Batting patterns for the leading",
      input$top_n,
      "batters in",
      input$season
    )
  })

  output$batters_plot <- renderPlot({
    ggplot(
      selected_batters(),
      aes(dot_percent, boundary_percent, size = balls_faced)
    ) +
      geom_point(colour = "#006DAE", alpha = 0.7) +
      labs(
        x = "Dot-ball percentage",
        y = "Boundary percentage",
        size = "Balls faced"
      ) +
      scale_x_continuous(labels = scales::label_percent(scale = 1)) +
      scale_y_continuous(labels = scales::label_percent(scale = 1)) +
      theme_minimal(base_size = 15)
  })
}

shinyApp(ui, server)

Instructor notes

The workshop is designed to produce a successful one-input/one-output app before introducing a second input and a reactive expression. Encourage students to run the app after every stage.

Common problems to watch for:

  • app.R and wpl_batters.csv are not in the same folder;
  • an ID differs between the UI and server;
  • students write season rather than input$season;
  • students write selected_batters rather than selected_batters();
  • renderPlot() is placed in the UI or plotOutput() in the server;
  • a missing comma or closing parenthesis occurs in page_sidebar(); and
  • the final ggplot expression is not returned from renderPlot().

If time is short, stop after Section 6. Students will still have built an app from scratch with two inputs and a reactive plot.