Build a Data Explorer Shiny App
A data explorer app lets users interact with datasets without writing code. They can upload their own data, apply filters, sort columns, and generate visualizations—all through a clean web interface. In this guide, you’ll build a complete data explorer from scratch using Shiny.
What you will build
By the end of this guide, you’ll have an app with:
- File upload capability (CSV, Excel)
- Dynamic column selection
- Filtering by any column value
- Sortable data table with pagination
- Histogram and scatter plot visualizations
- Download filtered data
Setting up your project
First, create a new Shiny project in RStudio or via the console:
# Install required packages if needed
install.packages(c("shiny", "ggplot2", "DT", "readr", "dplyr"))
# Create a new Shiny app
shiny::shinyApp(ui = ui, server = server)
Create a new folder called data-explorer and save your app as app.R.
Building the UI
The user interface needs to handle file input, sidebar controls for filtering, and the main area for displaying results:
library(shiny)
library(DT)
library(ggplot2)
library(readr)
library(dplyr)
ui <- fluidPage(
titlePanel("Interactive Data Explorer"),
sidebarLayout(
sidebarPanel(
fileInput("file", "Upload CSV File",
accept = c("text/csv", "text/comma-separated-values", ".csv")),
checkboxInput("header", "Has Header", TRUE),
selectInput("xvar", "X Variable", choices = NULL),
selectInput("yvar", "Y Variable", choices = NULL),
selectInput("fillvar", "Fill Variable", choices = NULL),
actionButton("update", "Apply Changes", class = "btn-primary")
),
mainPanel(
tabsetPanel(
tabPanel("Data Table", DTOutput("table")),
tabPanel("Histogram", plotOutput("hist")),
tabPanel("Scatter Plot", plotOutput("scatter"))
)
)
)
)
The sidebarPanel holds all your controls, while the mainPanel uses a tabsetPanel to switch between views.
Building the server
The server function handles reactivity—whenever a user changes an input, the outputs update automatically:
server <- function(input, output, session) {
# Reactive value to store the uploaded data
data <- reactive({
req(input$file)
read_csv(input$file$datapath, col_names = input$header)
})
# Update select inputs when data changes
observeEvent(data(), {
updateSelectInput(session, "xvar", choices = names(data()))
updateSelectInput(session, "yvar", choices = names(data()))
updateSelectInput(session, "fillvar", choices = c("None", names(data())))
})
# Filtered data based on user selection
filtered_data <- eventReactive(input$update, {
req(data())
data()
})
# Render the data table
output$table <- renderDT({
datatable(filtered_data(), options = list(pageLength = 10))
})
# Render histogram
output$hist <- renderPlot({
req(input$xvar, filtered_data())
ggplot(filtered_data(), aes(x = .data[[input$xvar]])) +
geom_histogram(bins = 30, fill = "steelblue", alpha = 0.7) +
labs(x = input$xvar, y = "Count") +
theme_minimal()
})
# Render scatter plot
output$scatter <- renderPlot({
req(input$xvar, input$yvar, filtered_data())
fill_val <- if (input$fillvar == "None") NULL else input$fillvar
p <- ggplot(filtered_data(), aes(x = .data[[input$xvar]], y = .data[[input$yvar]]))
if (!is.null(fill_val)) {
p <- p + aes(fill = .data[[fill_val]]) + geom_point(alpha = 0.6)
} else {
p <- p + geom_point(alpha = 0.6)
}
p + labs(x = input$xvar, y = input$yvar) + theme_minimal()
})
}
The key is using reactive() for values that change and eventReactive() for actions that should only trigger on specific events like button clicks.
Adding data download
Users often want to save their filtered results. Add this to your UI and server:
# In UI, add after the sidebarPanel controls:
downloadButton("download", "Download Filtered Data")
# In server, add:
output$download <- downloadHandler(
filename = function() {
paste("filtered_data_", Sys.Date(), ".csv", sep = "")
},
content = function(file) {
write_csv(filtered_data(), file)
}
)
Running your app
Run the app from RStudio by clicking “Run App” or from the console:
shiny::runApp("data-explorer")
Your data explorer is now ready. Users can upload any CSV file, explore its structure, filter views, and download results.
Understanding reactivity
Shiny’s reactivity system is what makes your app interactive. When a user changes an input, any output that depends on that input automatically re-renders. This happens through a dependency graph that Shiny builds automatically.
The reactive() function creates a reactive value—a piece of data that changes over time. Whenever something that depends on a reactive value changes, Shiny knows to re-execute any code that uses that value. In our app, the data() reactive value depends on input$file, so whenever a user uploads a file, everything that depends on data() re-runs.
The observeEvent() function runs code only when a specific event occurs. We use it to update the dropdown menus whenever new data loads. Without observeEvent(), the menus would never update because selectInputs don’t automatically react to changes in their choices.
The eventReactive() function is similar but returns a value. We use it for the “Apply Changes” button so that filtering only happens when the user clicks the button, rather than continuously as they adjust settings. This gives users a chance to make multiple changes before seeing results.
Adding advanced filtering
You can extend your data explorer with more sophisticated filtering options. Here’s how to add column-specific filters:
# Add to UI
ui <- fluidPage(
# ... existing UI code ...
sidebarPanel(
# ... existing controls ...
# Add dynamic filter inputs
uiOutput("filterControls"),
actionButton("addFilter", "Add Filter", class = "btn-secondary"),
actionButton("clearFilters", "Clear All", class = "btn-outline")
)
)
# Add to server
output$filterControls <- renderUI({
req(data())
filter_list <- lapply(seq_len(input$numFilters), function(i) {
selectInput(paste0("filterCol", i), "Filter Column",
choices = c("None", names(data())))
})
tagList(filter_list)
})
observeEvent(input$addFilter, {
updateNumericInput(session, "numFilters",
value = isolate(input$numFilters + 1))
})
observeEvent(input$clearFilters, {
updateNumericInput(session, "numFilters", value = 0)
})
This pattern uses dynamic UI generation—creating inputs on the fly based on the data structure. The isolate() function prevents infinite loops by stopping reactivity propagation.
Customizing the appearance
Shiny apps can be styled using CSS. Add a custom stylesheet to match your brand:
ui <- fluidPage(
tags$head(
tags$style(HTML("
.btn-primary {
background-color: #2c3e50;
border-color: #2c3e50;
}
.btn-primary:hover {
background-color: #34495e;
}
.well {
background-color: #ecf0f1;
}
"))
),
# ... rest of UI
)
You can also use Bootstrap themes. The shiny package includes several built-in themes, or use the bslib package for more control:
library(bslib)
ui <- fluidPage(
theme = bs_theme(version = 5, bootswatch = "cerulean"),
# ... your UI components
)
Deployment options
When your app is ready to share, you have several deployment options:
Shinyapps.io: The easiest option for beginners. Sign up at shinyapps.io, connect your account, and deploy with one click from RStudio:
rsconnect::setAccountInfo(name="your-account", token="your-token", secret="your-secret")
rsconnect::deployApp("data-explorer")
RStudio Connect: For organizations needing more control. This is a paid product with enterprise features like authentication and scheduling.
Self-hosted: Deploy on your own server using Shiny Server or Docker. This gives you full control but requires more setup.
Each option has trade-offs. For personal projects or small teams, shinyapps.io is usually the best starting point.
Reactive data filtering patterns
The core pattern for a data explorer is a reactive expression that applies filters: filtered_data <- reactive({ data |> filter(x > input$min_x, y < input$max_y) }). All downstream outputs (renderTable, renderPlot, renderValueBox) call filtered_data(), automatically updating when any filter changes.
For many simultaneous filters, Reduce() or purrr::reduce() can apply a list of filter conditions programmatically. This scales better than manually adding & condition terms to a long filter chain.
Performance for large datasets
For datasets larger than ~100K rows, server-side filtering is essential, returning the full dataset to the browser causes slowness. DT::renderDataTable() with server = TRUE handles pagination and sorting on the server. For very large data, DuckDB or Arrow-backed reactive expressions compute aggregations before sending data to the UI.
Debouncing reactive inputs with debounce() prevents expensive computations from triggering on every keystroke in a text search box. input$search |> debounce(500) waits 500ms after the last keystroke before triggering downstream reactives.
Loading the full dataset into memory at app startup and filtering reactively in R is appropriate for datasets up to a few hundred thousand rows. Larger datasets require either pre-aggregation to reduce the in-memory footprint, database-backed queries where the filtering happens in the database and only results are returned to R, or server-side table virtualization that only sends visible rows to the browser.
Caching computed results with bindCache reduces the cost of repeated identical queries. If a user returns to a previously used filter combination, bindCache serves the cached result without re-running the computation. The cache key is the filter combination, so different filter values produce different cache entries. For expensive aggregation queries that recur frequently, caching can reduce response times from seconds to milliseconds.
Download handlers
downloadHandler() allows users to export filtered data or plots. downloadButton("download_csv", "Download CSV") in the UI, and in the server: output$download_csv <- downloadHandler(filename = "filtered_data.csv", content = function(file) write.csv(filtered_data(), file)). The content function receives a file path and should write the export to that path.
For accessible exploratory data analysis without custom Shiny development, esquisse::esquisser(df) launches an interactive ggplot2 builder for any data frame. It provides drag-and-drop variable assignment, filter controls, and exports the generated ggplot2 code, useful both as a standalone tool and as a starting point for custom app development.
Architecture notes
A data explorer Shiny app benefits from a clear separation between data access, transformation, and display. Fetch or read data once at startup and cache it in a reactive() that other observers depend on, avoid re-reading files inside event handlers. For large datasets, defer expensive computations until the user requests them using bindEvent() rather than reacting to every input change. DT::datatable() handles pagination and client-side filtering, reducing the amount of work done in the server. When the dataset is too large for the browser, implement server-side processing with DT::renderDT(server = TRUE).
Data explorer design pattern
A data explorer is a Shiny application pattern where users filter, group, and visualize a dataset through UI controls without writing code. The application accepts user selections and translates them into data operations, updating charts and tables reactively. This pattern appears in dashboards, internal data tools, and self-service analytics applications where non-technical users need flexible access to data.
Building a data explorer requires designing the filter and grouping controls to match the dataset’s structure. Categorical columns become dropdown filters or checkboxes. Numeric columns become range sliders or threshold inputs. Date columns become date range pickers. The controls define what slices of data the user can access. Too many controls overwhelms users; too few limits what questions they can ask. Start with the questions users most frequently ask and build controls for those.
Reactive data pipelines
The core logic in a data explorer is a reactive expression that filters the source data based on the current control values. This reactive dataset is the single source of truth that all other reactives read from. Charts and tables that depend on the filtered data read this reactive expression, not the raw source data. When a filter changes, the reactive dataset updates and all downstream outputs update automatically.
Chaining reactive expressions creates a reactive pipeline. The raw data flows through filter reactive → grouping reactive → summary reactive → chart outputs. Each stage depends only on the previous stage, not on individual input controls. This separation of concerns makes the pipeline easier to reason about than having every chart reactive read all control inputs directly.
See also
- Build a Sales Dashboard with Shiny, A more advanced dashboard project with KPIs and multiple visualizations
- Getting Started with Shiny, Core Shiny concepts for building your first app
- Organising Shiny Apps with Modules — Scale your apps with reusable components
shiny::exportTestValues()exposes reactive values for testing withshinytest2without modifying the application’s user-facing behavior.