How I Built My First Shiny App
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.
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:
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.
# 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.