← All writing

How I Built My First Shiny App

R / Shiny 8 min read• NFL Data Explorer

It took me about four hours to get something on screen and another three weeks to understand why it worked. This is the part nobody puts in the tutorials.

The app was called the NFL Data Explorer. You picked a season and a stat, and it drew you a chart of the league leaders. That is the entire feature set. It is not impressive and I am not going to pretend it was — but it was the first thing I built that another person could open and use without me standing next to them, and that turned out to be a completely different skill from writing a script.

Where it is now

The hosted version is offline — it lived on a free shinyapps.io tier that lapsed, and shinyapps.io now returns a 404 for it. Rather than link you to a dead frame from my projects page, I wrote this up instead. Everything below is from the code I still have.

Shiny in one paragraph

A Shiny app is two functions. ui describes what is on the screen. server describes what happens when things change. You hand both to shinyApp() and R runs a web server.

The smallest thing that does anything:

library(shiny)

ui <- fluidPage(
  selectInput("season", "Season", choices = 2018:2025),
  plotOutput("chart")
)

server <- function(input, output) {
  output$chart <- renderPlot({
    plot_leaders(input$season)
  })
}

shinyApp(ui, server)

The thing that took me longest to actually understand is that I never wrote any code saying "when the dropdown changes, redraw the chart." There is no event handler. I never called plot_leaders myself.

Reactivity, and the thing that finally made it click

What happens is that renderPlot watches which inputs get read while its code runs. My block reads input$season, so Shiny quietly writes down "this chart depends on season." When season changes, anything that read it gets invalidated and re-runs. Nothing else does.

I understood that as a sentence for weeks before I understood it as a mechanism. What made it click was breaking it. I moved my data loading inside the render block:

Before — reloads a 200 MB file on every interaction
server <- function(input, output) {
  output$chart <- renderPlot({
    pbp <- load_pbp(2018:2025)      # <-- runs again on EVERY change
    pbp |>
      filter(season == input$season) |>
      plot_leaders()
  })
}

The app still worked. It was just unusable — every dropdown change froze the browser for about eight seconds. I genuinely thought R was slow, which is embarrassing in hindsight. R was fine. I was asking it to re-download and re-parse eight seasons of play-by-play every time somebody clicked anything.

After — load once, filter reactively
# Outside both functions: runs exactly once, when the app starts.
pbp <- load_pbp(2018:2025)

server <- function(input, output) {
  # A reactive is a cached value that recomputes only when what it
  # reads changes. This one re-runs on a season change, and not
  # otherwise -- and two different outputs can share it.
  season_data <- reactive({
    pbp |> filter(season == input$season)
  })

  output$chart <- renderPlot({ plot_leaders(season_data()) })
  output$table <- renderDT({ season_data() |> head(50) })
}

Eight seconds to instant. And the shape of the fix is the actual lesson: work belongs at the outermost level where its inputs stop changing. Loading never changes, so it goes at the top. Filtering changes with the season, so it goes in a reactive. Drawing changes with everything, so it goes in the render block.

I still use that rule constantly. When I built Hoop Vision two years later, the same idea is why every expensive calculation happens in a build script that runs once, and the app only reads the result. That is the same decision, moved one level further out.

Three things I got wrong

I built the UI before I knew what it should show

I spent a full evening on a sidebar layout with tabs, a slider, three dropdowns and a download button. Then I built the actual analysis and discovered that two of the dropdowns controlled things nobody would ever want to change, and the slider filtered on a variable I ended up not using.

Now I sketch on paper first. Not because paper is magic, but because it takes forty seconds instead of an evening, so I am willing to throw it away.

I put logic inside render blocks

Anything inside renderPlot can only be tested by launching the app and clicking. Anything in a plain function can be tested at the console. So plain functions, called from render blocks, always:

I had no empty state

If you picked a filter combination with no rows, ggplot threw an error and a red stack trace appeared in the middle of the page. To a user that reads as "this is broken," not "no data matches."

output$chart <- renderPlot({
  d <- season_data()
  validate(need(nrow(d) > 0, "No players match those filters."))
  plot_leaders(d, input$stat)
})

validate(need(...)) shows a plain message instead of a traceback. It is two lines and it is the difference between an app that looks broken and one that looks finished.

What I'd tell myself starting over

Get something ugly on screen in the first hour. My instinct was to plan the whole thing first, and planning a framework you have never used is mostly inventing constraints that turn out to be wrong.

Then, once it works at all, ask where each piece of work actually belongs. That single question — load once, filter per-selection, draw per-render — fixed the only real performance problem I had, and I have used it in every project since.

← All writing Next: QB analysis with nflfastR →