Tables and plots
Lesson 6 — R Shiny
Learning objectives
- Build interactive tables with
DTandreactable, including formatting - Capture and respond to table selection and filtering
- Render
ggplot2plots that look right in a browser - Use click, hover and brush events to drive other outputs
- Add interactivity with
plotlyand know its trade-offs - Link tables and plots into a coherent exploration workflow
Tables
DT
The workhorse. A JavaScript DataTables wrapper with good Shiny integration.
library(DT)
output$table <- renderDT({
datatable(
filtered(),
rownames = FALSE,
filter = "top", # per-column filter boxes
selection = list(mode = "single", target = "row"),
extensions = c("Buttons", "FixedHeader"),
options = list(
pageLength = 25,
lengthMenu = c(10, 25, 50, 100),
scrollX = TRUE,
fixedHeader = TRUE,
dom = "Blfrtip",
buttons = list(
list(extend = "csv", text = "CSV", filename = "adsl_export"),
list(extend = "excel", text = "Excel", filename = "adsl_export")
),
columnDefs = list(
list(className = "dt-center", targets = c(2, 3)),
list(visible = FALSE, targets = 0)
)
)
) |>
formatRound(c("AVAL", "CHG", "PCHG"), digits = 2) |>
formatPercentage("PCT", digits = 1) |>
formatDate("ADT", method = "toLocaleDateString") |>
formatStyle("CHG",
color = styleInterval(0, c("#a32e46", "#1f7a52")),
fontWeight = "bold") |>
formatStyle("AVAL",
background = styleColorBar(range(filtered()$AVAL, na.rm = TRUE), "#dff2f4"),
backgroundSize = "98% 88%",
backgroundRepeat = "no-repeat",
backgroundPosition = "center")
})The dom string controls which DataTables elements appear and in what order: B buttons, l length menu, f filter, r processing, t table, i info, p pagination.
Reacting to the table
DT exposes several inputs automatically, named after the output ID:
input$table_rows_selected # integer indices of selected rows
input$table_rows_all # indices after filtering, all pages
input$table_rows_current # indices on the current page
input$table_cell_clicked # list(row, col, value)
input$table_search # global search string
input$table_state # full state, for bookmarkingselected_subject <- reactive({
idx <- input$table_rows_selected
if (is.null(idx)) return(NULL)
filtered()[idx, ]
})
output$detail <- renderUI({
s <- selected_subject()
validate(need(!is.null(s), "Select a subject to see their details."))
tagList(
h4(s$USUBJID),
tags$dl(
tags$dt("Age"), tags$dd(s$AGE),
tags$dt("Sex"), tags$dd(s$SEX),
tags$dt("Arm"), tags$dd(s$TRT01P)
)
)
})
# Download exactly what the user is looking at
output$download <- downloadHandler(
filename = function() paste0("filtered_", Sys.Date(), ".csv"),
content = function(file) {
readr::write_csv(filtered()[input$table_rows_all, ], file)
}
)That last pattern — downloading rows_all rather than the whole dataset — matches what the user expects when they have typed into the column filters.
Updating without redrawing
Redrawing a large table loses the user’s scroll position, page and filters:
proxy <- dataTableProxy("table")
observeEvent(input$refresh, {
replaceData(proxy, new_data(), resetPaging = FALSE, rownames = FALSE)
})
observeEvent(input$clear_selection, {
selectRows(proxy, NULL)
})
observeEvent(input$goto, {
selectPage(proxy, ceiling(input$goto / 25))
})Editable tables
output$table <- renderDT({
datatable(values(), editable = list(target = "cell",
disable = list(columns = c(0, 1))))
})
observeEvent(input$table_cell_edit, {
info <- input$table_cell_edit
d <- values()
d[info$row, info$col + 1] <- DT::coerceValue(info$value, d[info$row, info$col + 1])
values(d)
})coerceValue() converts the edited string back to the column’s type and warns if it cannot. Editable tables need careful validation — an edit that silently becomes NA is worse than a rejected edit.
reactable
A newer alternative with a more R-native API, better nested/grouped display and built-in sparklines.
library(reactable)
output$table <- renderReactable({
reactable(
filtered(),
groupBy = "TRT01P",
searchable = TRUE,
filterable = TRUE,
defaultPageSize = 20,
selection = "single",
onClick = "select",
highlight = TRUE,
striped = TRUE,
columns = list(
USUBJID = colDef(name = "Subject", sticky = "left", minWidth = 140),
AGE = colDef(name = "Age", format = colFormat(digits = 0),
aggregate = "mean"),
CHG = colDef(
name = "Change",
style = function(value) {
list(color = if (value < 0) "#a32e46" else "#1f7a52",
fontWeight = "bold")
},
format = colFormat(digits = 2)
),
SAFFL = colDef(name = "Safety", cell = function(v) {
if (v == "Y") "✓" else "–"
})
)
)
})
selected <- reactive(getReactableState("table", "selected"))DT |
reactable |
|
|---|---|---|
| Maturity | Very mature, huge install base | Newer, actively developed |
| API | DataTables options (JS-flavoured) | R functions, more discoverable |
| Grouping / nesting | Awkward | Excellent |
| Custom cell rendering | JS callbacks | R functions |
| Export buttons | Built in (extensions) | Roll your own |
| Very large data | Server-side processing | Client-side only |
Use DT when you need server-side processing for hundreds of thousands of rows, or the export buttons. Use reactable when the display logic is complex.
Plots
renderPlot
output$plot <- renderPlot({
validate(need(nrow(filtered()) > 0, "No data to plot."))
ggplot(filtered(), aes(AVISITN, AVAL, colour = TRT01P, group = USUBJID)) +
geom_line(alpha = 0.25) +
stat_summary(aes(group = TRT01P), fun = mean, geom = "line", linewidth = 1.2) +
scale_colour_manual(values = c("Placebo" = "#7a8698",
"Drug A" = "#16355e",
"Drug B" = "#0a8f9e")) +
labs(x = "Visit (weeks)", y = "Value", colour = "Treatment") +
theme_minimal(base_size = 13)
}, res = 96)res = 96 matches the browser’s pixel density, so text in the plot is the size you expect. Without it, everything is noticeably too small. Set it on every renderPlot().
Sizing:
plotOutput("plot", height = "500px")
plotOutput("plot", height = "auto") # fills the container (needs a sized parent)
output$plot <- renderPlot({...},
width = function() session$clientData$output_plot_width,
height = function() max(400, nrow(data()) * 20) # taller with more rows
)The height-from-data pattern matters for forest plots and AE plots, where a fixed height either wastes space or crushes the labels.
Interaction
ui <- plotOutput("plot",
click = "plot_click",
dblclick = "plot_dblclick",
hover = hoverOpts("plot_hover", delay = 200, delayType = "debounce"),
brush = brushOpts("plot_brush", resetOnNew = TRUE, direction = "xy")
)server <- function(input, output, session) {
# Click: identify the nearest point
clicked <- reactive({
req(input$plot_click)
nearPoints(filtered(), input$plot_click,
xvar = "AVISITN", yvar = "AVAL",
threshold = 10, maxpoints = 1)
})
# Brush: select a region
brushed <- reactive({
req(input$plot_brush)
brushedPoints(filtered(), input$plot_brush)
})
# Double click to zoom, double click on empty space to reset
ranges <- reactiveValues(x = NULL, y = NULL)
observeEvent(input$plot_dblclick, {
b <- input$plot_brush
if (!is.null(b)) {
ranges$x <- c(b$xmin, b$xmax)
ranges$y <- c(b$ymin, b$ymax)
} else {
ranges$x <- NULL
ranges$y <- NULL
}
})
output$plot <- renderPlot({
ggplot(filtered(), aes(AVISITN, AVAL)) +
geom_point() +
coord_cartesian(xlim = ranges$x, ylim = ranges$y, expand = FALSE)
}, res = 96)
# Hover tooltip
output$hover_info <- renderUI({
req(input$plot_hover)
pt <- nearPoints(filtered(), input$plot_hover, threshold = 8, maxpoints = 1)
req(nrow(pt) == 1)
style <- sprintf(
"position:absolute; left:%dpx; top:%dpx; background:rgba(255,255,255,0.95);
border:1px solid #c9d1dc; border-radius:6px; padding:6px 10px;
font-size:0.85rem; pointer-events:none; z-index:100;",
round(input$plot_hover$coords_css$x) + 12,
round(input$plot_hover$coords_css$y) + 12
)
tags$div(style = style,
tags$strong(pt$USUBJID), tags$br(),
sprintf("Week %d: %.2f", pt$AVISITN, pt$AVAL))
})
}The hover tooltip needs position: relative on the containing div for the absolute positioning to work.
plotly
Interactivity without writing the event handling:
library(plotly)
output$plot <- renderPlotly({
p <- ggplot(filtered(), aes(AVISITN, AVAL, colour = TRT01P,
text = paste0("Subject: ", USUBJID,
"<br>Value: ", round(AVAL, 2)))) +
geom_point() +
theme_minimal()
ggplotly(p, tooltip = "text", source = "main") |>
layout(dragmode = "select") |>
config(displayModeBar = TRUE,
modeBarButtonsToRemove = c("lasso2d", "autoScale2d"))
})
# Read plotly events
selected <- reactive({
ev <- event_data("plotly_selected", source = "main")
req(ev)
filtered()[ev$pointNumber + 1, ]
})
clicked <- reactive({
ev <- event_data("plotly_click", source = "main")
req(ev)
filtered()[ev$pointNumber + 1, ]
})plotly trade-offs
ggplotly() does not translate every ggplot2 feature — faceting with free scales, some geoms, and most theme() details are approximated or lost. It also sends all the data to the browser, so 100,000 points will make the page unresponsive.
For a plot that must exactly match a static output (a TLF, for instance), use renderPlot() with Shiny’s own click and brush handling. For an exploratory plot with a few thousand points, plotly saves a great deal of code.
Caching and performance
output$plot <- renderPlot({
make_expensive_plot(input$param, input$arm)
}) |>
bindCache(input$param, input$arm)For very large point counts:
# Rasterise the point layer, keep axes and text as vectors
library(ggrastr)
ggplot(big_data, aes(x, y)) + rasterise(geom_point(), dpi = 150)
# Or aggregate rather than plotting every point
ggplot(big_data, aes(x, y)) + geom_hex(bins = 60)Linked views
The pattern that makes a review app useful: selection in one view drives every other view.
server <- function(input, output, session) {
# Single source of truth for the selection
selected_subject <- reactiveVal(NULL)
# Table selection
observeEvent(input$table_rows_selected, {
selected_subject(filtered()$USUBJID[input$table_rows_selected])
})
# Plot click
observeEvent(input$plot_click, {
pt <- nearPoints(filtered(), input$plot_click, maxpoints = 1)
if (nrow(pt) == 1) selected_subject(pt$USUBJID)
})
# Clear
observeEvent(input$clear, selected_subject(NULL))
# Every view reads the same value
output$plot <- renderPlot({
p <- ggplot(filtered(), aes(AVISITN, AVAL, group = USUBJID)) +
geom_line(colour = "grey80")
if (!is.null(selected_subject())) {
p <- p + geom_line(
data = filter(filtered(), USUBJID == selected_subject()),
colour = "#0a8f9e", linewidth = 1.4
)
}
p + theme_minimal(base_size = 13)
}, res = 96)
output$detail <- renderUI({
req(selected_subject())
subject_card(filter(adsl, USUBJID == selected_subject()))
})
}Routing everything through one reactiveVal is what prevents the table and the plot disagreeing about what is selected.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
No res = 96 |
Plot text too small | Add it |
Re-rendering a big DT |
Loses page, filters, scroll | replaceData() via proxy |
| Downloading the full dataset | Ignores user’s filters | Use input$table_rows_all |
plotly with 100k points |
Browser hangs | renderPlot() or aggregate |
| Table and plot each own the selection | They disagree | One reactiveVal |
nearPoints() without xvar/yvar |
No match on transformed scales | Name the variables |
| Fixed plot height for variable-length data | Crushed or empty | Compute height from nrow() |
Exercise 6.1 — Table-driven detail view
Build an app with a DT of subjects. Selecting a row shows that subject’s lab values over time in a plot below, and clears cleanly when the selection is removed.
Show solution
library(shiny); library(bslib); library(DT); library(dplyr); library(ggplot2)
ui <- page_fillable(
title = "Subject lab review",
card(
card_header("Subjects"),
DTOutput("subjects"), height = "45%"
),
card(
card_header(textOutput("plot_title", inline = TRUE)),
plotOutput("labplot")
)
)
server <- function(input, output, session) {
adsl <- reactive(readRDS("data/adsl.rds"))
adlb <- reactive(readRDS("data/adlb.rds"))
output$subjects <- renderDT({
datatable(
adsl() |> select(USUBJID, TRT01P, AGE, SEX, SAFFL),
rownames = FALSE,
selection = "single",
filter = "top",
options = list(pageLength = 8, scrollX = TRUE)
)
})
selected_id <- reactive({
idx <- input$subjects_rows_selected
if (is.null(idx)) NULL else adsl()$USUBJID[idx]
})
subject_labs <- reactive({
req(selected_id())
adlb() |>
filter(USUBJID == selected_id()) |>
arrange(PARAMCD, AVISITN)
})
output$plot_title <- renderText({
if (is.null(selected_id())) "Lab profile" else
paste("Lab profile —", selected_id())
})
output$labplot <- renderPlot({
validate(need(!is.null(selected_id()), "Select a subject above."))
validate(need(nrow(subject_labs()) > 0,
paste("No lab records for", selected_id())))
ggplot(subject_labs(), aes(AVISITN, AVAL)) +
geom_line(colour = "#16355e") +
geom_point(size = 2, colour = "#16355e") +
geom_hline(aes(yintercept = ANRLO), linetype = "dashed",
colour = "#a32e46", na.rm = TRUE) +
geom_hline(aes(yintercept = ANRHI), linetype = "dashed",
colour = "#a32e46", na.rm = TRUE) +
facet_wrap(~ PARAM, scales = "free_y", ncol = 3) +
labs(x = "Visit (weeks)", y = "Result") +
theme_minimal(base_size = 12) +
theme(strip.text = element_text(face = "bold"))
}, res = 96)
}The reference-range lines are what makes this a review tool rather than a demo — a lab value is only interpretable against its normal range. Note na.rm = TRUE on the geom_hline() calls: parameters without a defined range would otherwise emit warnings for every row.
validate() distinguishes “nothing selected” from “selected but no data”, which are different situations for the reviewer.
Exercise 6.2 — Brush to filter
Build a scatter plot where brushing a region filters a table below to the brushed points, with a button to clear the brush. Show how many points are selected.
Show solution
library(shiny); library(bslib); library(DT); library(dplyr); library(ggplot2)
ui <- page_fillable(
card(
card_header(
class = "d-flex justify-content-between align-items-center",
"Baseline vs change — drag to select",
actionButton("clear", "Clear selection", class = "btn-sm btn-outline-secondary")
),
plotOutput("scatter", brush = brushOpts("brush", resetOnNew = TRUE,
fill = "#0a8f9e", opacity = 0.2)),
height = "55%"
),
card(
card_header(textOutput("n_selected", inline = TRUE)),
DTOutput("table")
)
)
server <- function(input, output, session) {
adlb <- reactive({
readRDS("data/adlb.rds") |> filter(PARAMCD == "ALT", AVISITN == 12)
})
# Clearing: bump a counter that the selection reactive depends on
cleared <- reactiveVal(0)
observeEvent(input$clear, {
session$resetBrush("brush")
cleared(cleared() + 1)
})
selected <- reactive({
cleared() # dependency so clearing re-runs this
b <- input$brush
if (is.null(b)) return(adlb()[0, ]) # empty, same columns
brushedPoints(adlb(), b, xvar = "BASE", yvar = "CHG")
})
output$scatter <- renderPlot({
p <- ggplot(adlb(), aes(BASE, CHG)) +
geom_hline(yintercept = 0, colour = "grey70") +
geom_point(colour = "grey65", size = 2)
if (nrow(selected()) > 0) {
p <- p + geom_point(data = selected(), colour = "#0a8f9e", size = 2.6)
}
p +
labs(x = "Baseline ALT (U/L)", y = "Change from baseline (U/L)") +
theme_minimal(base_size = 13)
}, res = 96)
output$n_selected <- renderText({
if (nrow(selected()) == 0) {
sprintf("All %d subjects (no selection)", nrow(adlb()))
} else {
sprintf("%d of %d subjects selected", nrow(selected()), nrow(adlb()))
}
})
output$table <- renderDT({
d <- if (nrow(selected()) > 0) selected() else adlb()
datatable(
d |> select(USUBJID, TRT01P, BASE, AVAL, CHG, PCHG),
rownames = FALSE,
options = list(pageLength = 10, scrollX = TRUE)
) |>
formatRound(c("BASE", "AVAL", "CHG", "PCHG"), 2)
})
}Two subtleties:
session$resetBrush()alone is not enough. It clears the brush in the browser, but ifinput$brushwas alreadyNULL(or the round trip is delayed) the reactive may not re-evaluate. Thecleared()counter forces it.- Returning
adlb()[0, ]rather thanNULLwhen nothing is brushed keeps the return type stable, sonrow()always works and the downstream code needs nois.null()branches.
Recap
DTexposes_rows_selected,_rows_alland_cell_clickedautomatically- Download
input$table_rows_allto respect the user’s column filters - Use
dataTableProxy()+replaceData()instead of re-rendering large tables res = 96on everyrenderPlot()nearPoints()andbrushedPoints()for click and brush; namexvar/yvarplotlyis fast to write but does not scale past a few thousand points- Route every selection through one
reactiveValso views cannot disagree
Next: File uploads and downloads.