Introduction
Clinical-trial data review often involves moving between many different tables, listings, datasets, spreadsheets, and statistical programming outputs.
A statistician or clinical data reviewer may need to answer questions such as:
- Which subjects have abnormal laboratory results?
- Which subjects have serious adverse events?
- Which subjects discontinued treatment?
- Which visits are missing?
- Which subjects have inconsistent dates?
- Which patients have important changes in laboratory parameters?
- Which subjects have protocol or data-quality flags?
Traditional listings are useful, but they can become cumbersome when the reviewer repeatedly needs to filter, sort, cross-reference, and inspect individual subjects.
R Shiny provides a way to build an interactive review environment directly in R.
What Is Shiny?
Shiny is an R framework for building interactive web applications.
The fundamental architecture consists of two major components:
- UI: defines what the user sees and interacts with.
- Server: defines how the application responds to inputs.
A minimal Shiny application looks like this:
library(shiny)
ui <- fluidPage(
h1("Clinical Data Review")
)
server <- function(input, output, session) {
}
shinyApp(
ui = ui,
server = server
)
The application can then be launched from R.
shiny::runApp()
Why Use Shiny for Clinical Data Review?
The main advantage is that a reviewer can interact directly with the data.
Instead of generating a new listing every time a question arises, the reviewer can select:
- Treatment group
- Site
- Subject
- Visit
- Laboratory parameter
- Adverse-event category
- Data-quality flag
and have the application update the displayed information.
| Traditional Review | Shiny Review |
|---|---|
| Generate static listing | Filter interactively |
| Open multiple outputs | Navigate one integrated application |
| Re-run program for each filter | Reactive outputs update automatically |
| Review subject information across files | Combine related domains in one subject dashboard |
| Static plot | Interactive plot with subject selection |
| Manual export | Download filtered review data |
Clinical Data Review Is Different From Final Analysis
A Shiny review application should not automatically be considered the analysis environment used to generate primary statistical results.
Clinical data review is frequently exploratory and operational.
The purpose may be to identify:
- Potential data inconsistencies
- Outliers
- Unexpected values
- Missing assessments
- Potential coding issues
- Subject-level patterns
- Questions requiring clinical review
A production statistical analysis, by contrast, should generally follow the prespecified analysis plan and validated programming processes.
A Typical Clinical Data Review Architecture
The important principle is separation of responsibilities.
The underlying clinical data should be treated as the source data. The Shiny application provides a controlled presentation and review layer.
What Should a Clinical Review App Contain?
A useful application can contain several modules.
| Module | Purpose |
|---|---|
| Overview | Study-level counts and review metrics |
| Subject Review | Integrated patient-level information |
| Laboratory Review | Identify abnormal and changing laboratory values |
| Adverse Events | Review event severity, seriousness, relationship, and outcomes |
| Exposure | Review treatment exposure and dosing information |
| Vital Signs | Review longitudinal vital-sign measurements |
| Data Quality | Identify potential inconsistencies and missing information |
| Downloads | Export the currently filtered review dataset |
An Interactive Clinical Review Mockup
The following embedded demonstration is a browser-side illustration of the type of workflow a Shiny application can provide. It uses simulated data and does not connect to a real clinical database.
Drug A · Site 101
| Visit | ALT | ULN | Status |
|---|
What is happening conceptually? The reviewer selects filters, the application identifies the relevant subjects, and the selected subject drives the subject-level review panel. In a real Shiny application, these relationships would normally be implemented using reactive expressions and outputs on the server.
The Shiny Reactive Model
Reactive programming is the central concept to understand before building a larger Shiny application.
Suppose the user selects a treatment:
selectInput(
"treatment",
"Treatment",
choices = c("All", "Drug A", "Drug B")
)
The server can use that input to create a reactive dataset:
filtered_data <- reactive({
req(input$treatment)
if (input$treatment == "All") {
data
} else {
subset(
data,
TRT01P == input$treatment
)
}
})
Any output that depends on filtered_data() can
react when the input changes.
This dependency graph is the conceptual foundation of a Shiny application.
Inputs, Reactives, and Outputs
| Component | Example |
|---|---|
| Input | input$subject |
| Reactive expression | subject_data() |
| Output | output$lab_table |
| UI placeholder | DTOutput("lab_table") |
Building the UI
A clinical review interface often begins with a sidebar containing filters and a main panel containing the review outputs.
library(shiny)
library(DT)
ui <- fluidPage(
titlePanel(
"Clinical Data Review"
),
sidebarLayout(
sidebarPanel(
selectInput(
"treatment",
"Treatment",
choices = c(
"All",
"Drug A",
"Drug B"
)
),
selectInput(
"subject",
"Subject",
choices = NULL
)
),
mainPanel(
tabsetPanel(
tabPanel(
"Overview",
DTOutput("overview")
),
tabPanel(
"Laboratory",
DTOutput("labs")
),
tabPanel(
"Adverse Events",
DTOutput("ae")
)
)
)
)
)
Building the Server
The server function contains the application's behavior.
server <- function(input, output, session) {
filtered_data <- reactive({
req(input$treatment)
if (input$treatment == "All") {
data
} else {
subset(
data,
TRT01P == input$treatment
)
}
})
output$overview <- renderDT({
datatable(
filtered_data()
)
})
}
The key pattern is:
reactive({
...
})
renderDT({
...
})
The reactive expression creates data, while the render function turns the data into an output.
Loading Clinical Data
For a prototype, a CSV file may be sufficient.
data <- read.csv( "clinical_data.csv", stringsAsFactors = FALSE )
For larger applications, more structured data-loading strategies may be appropriate.
Possible sources include:
- CSV files
- RDS files
- Parquet files
- Relational databases
- SQL queries
- Controlled application data services
Clinical Data Often Uses Multiple Domains
A realistic review application may combine several datasets.
For example:
dm <- readRDS("dm.rds")
lb <- readRDS("lb.rds")
ae <- readRDS("ae.rds")
ex <- readRDS("ex.rds")
vs <- readRDS("vs.rds")
The datasets can then be linked through the appropriate subject identifier.
For example:
subject_dm <- reactive({
req(input$subject)
dm[
dm$USUBJID == input$subject,
]
})
Laboratory data can similarly be filtered:
subject_lb <- reactive({
req(input$subject)
lb[
lb$USUBJID == input$subject,
]
})
Do Not Treat All Data Domains as Equivalent
A clinical reviewer may see information from several domains on one screen, but each domain can have different structures and semantics.
| Domain | Typical Review Focus |
|---|---|
| DM | Subject demographics, treatment, disposition context |
| LB | Laboratory values, abnormalities, trends |
| AE | Event timing, severity, seriousness, relationship, outcome |
| EX | Treatment exposure and dosing |
| VS | Longitudinal vital-sign changes |
| EG | ECG measurements and review status |
| CM | Concomitant medications |
The application should preserve the meaning of the underlying data rather than flattening everything into a single ambiguous table.
Subject Selection
A particularly useful feature is dynamically updating the subject list after the user changes other filters.
observe({
subjects <- filtered_data()$USUBJID
updateSelectInput(
session,
"subject",
choices = sort(unique(subjects))
)
})
The dependency is:
Using req() to Protect Reactive Logic
Shiny outputs can execute before all inputs are available.
The req() function prevents downstream code from
running until a required input exists.
subject_data <- reactive({
req(input$subject)
data[
data$USUBJID == input$subject,
]
})
This is particularly useful in clinical applications because incomplete initialization should not result in confusing errors.
Displaying Clinical Listings With DT
The DT package provides interactive HTML tables
that work particularly well for clinical review.
library(DT)
output$labs <- renderDT({
datatable(
subject_lb(),
filter = "top",
rownames = FALSE,
options = list(
pageLength = 15,
scrollX = TRUE
)
)
})
The reviewer can then search and sort the displayed observations without regenerating the underlying data.
Formatting Clinical Laboratory Tables
A laboratory review table might contain:
| Variable | Example |
|---|---|
| USUBJID | ABC-001 |
| LBTEST | ALT |
| VISIT | Week 8 |
| LBSTRESN | 86 |
| LBSTRESU | U/L |
| LBORNRLO | 0 |
| LBORNRHI | 40 |
| LBSTAT |
A review flag can then be derived.
lb_review <- reactive({
x <- subject_lb()
x$REVIEW_FLAG <-
!is.na(x$LBSTRESN) &
!is.na(x$LBORNRHI) &
x$LBSTRESN > x$LBORNRHI
x
})
Why Review Flags Should Be Separate Variables
It is often better to derive a clearly named review variable than to overwrite the source value.
For example:
x$REVIEW_FLAG <- x$LBSTRESN > x$LBORNRHI
is preferable to changing LBSTRESN itself.
Displaying Flags in DT
Conditional formatting can draw attention to observations requiring review.
datatable(
lb_review(),
rownames = FALSE
) %>%
formatStyle(
"REVIEW_FLAG",
backgroundColor = styleEqual(
c(TRUE, FALSE),
c("#E7D6B0", "#FFFFFF")
)
)
For a regulated or controlled environment, the visual flagging logic should itself be tested and documented.
Reviewing Adverse Events
Adverse-event review is another natural application for Shiny.
A subject-level AE panel might show:
- AE term
- Start date
- End date
- Severity
- Seriousness
- Relationship to study treatment
- Action taken
- Outcome
output$ae <- renderDT({
datatable(
ae[
ae$USUBJID == input$subject,
],
rownames = FALSE,
filter = "top"
)
})
Highlighting Serious Adverse Events
A derived flag can identify serious adverse events.
ae_review <- reactive({
x <- subject_ae()
x$SAE_REVIEW_FLAG <-
x$AESER == "Y"
x
})
The application can then provide a summary:
output$sae_count <- renderText({
sum(
ae_review()$SAE_REVIEW_FLAG,
na.rm = TRUE
)
})
Clinical Review Flags Should Not Be Overinterpreted
A flag is generally an indication that something deserves attention.
It does not necessarily mean that the underlying record is incorrect.
For example:
- An ALT above the reference range may be clinically expected.
- A missing value may be legitimately unavailable.
- An AE ending after treatment discontinuation may be appropriate.
- A date discrepancy may arise from different collection conventions.
Creating a Laboratory Trend Plot
A longitudinal laboratory plot can be especially useful for reviewing potentially important changes over time.
library(ggplot2)
output$lab_plot <- renderPlot({
x <- subject_lb()
ggplot(
x,
aes(
x = ADY,
y = LBSTRESN
)
) +
geom_line() +
geom_point() +
labs(
x = "Study Day",
y = input$lab_parameter
) +
theme_classic()
})
The reviewer can select a laboratory parameter and subject, allowing the plot to update automatically.
Reference Ranges
If a laboratory reference range is available, it can be displayed visually.
ggplot(
x,
aes(
x = ADY,
y = LBSTRESN
)
) +
geom_line() +
geom_point() +
geom_hline(
yintercept = upper_limit,
linetype = "dashed"
) +
geom_hline(
yintercept = lower_limit,
linetype = "dashed"
)
The exact handling of reference ranges requires care because reference ranges may vary by laboratory, test, unit, sex, age, and other factors.
Do Not Assume One Reference Range
This is a common clinical-data programming mistake.
Consider two laboratory measurements:
| Subject | Laboratory | Test | Upper Limit |
|---|---|---|---|
| 001 | Central Lab A | ALT | 40 |
| 002 | Central Lab B | ALT | 45 |
Applying 40 U/L to both subjects could generate an incorrect review flag.
Reactive Expressions for Complex Review Logic
As applications become larger, reactive expressions should perform logically coherent transformations rather than becoming giant blocks of code.
For example:
filtered_subjects <- reactive({
...
})
selected_subject <- reactive({
...
})
selected_labs <- reactive({
...
})
selected_ae <- reactive({
...
})
subject_summary <- reactive({
...
})
This creates a dependency structure that is easier to understand and test.
Reactive Dependency Graph
The goal is to make the dependencies explicit.
Using dplyr Inside Shiny
The tidyverse can make clinical review transformations concise.
library(dplyr)
filtered_data <- reactive({
data %>%
filter(
input$treatment == "All" |
TRT01P == input$treatment
) %>%
filter(
input$site == "All" |
SITEID == input$site
)
})
This is particularly useful when several filters must be applied together.
Multiple Filters
A more complete example might look like:
filtered_data <- reactive({
x <- data
if (input$treatment != "All") {
x <- x %>%
filter(
TRT01P == input$treatment
)
}
if (input$site != "All") {
x <- x %>%
filter(
SITEID == input$site
)
}
x
})
The advantage is that each filter is applied only when the reviewer requests it.
Filtering by Review Flag
A particularly useful filter is:
selectInput(
"review_status",
"Review Status",
choices = c(
"All",
"Flagged Only",
"No Current Flags"
)
)
Then:
review_data <- reactive({
x <- filtered_data()
if (
input$review_status ==
"Flagged Only"
) {
x <- x %>%
filter(REVIEW_FLAG)
}
x
})
Using validate() for Clinical Review Outputs
Sometimes an output should not attempt to render when no suitable data are available.
output$lab_plot <- renderPlot({
x <- subject_lb()
validate(
need(
nrow(x) > 0,
"No laboratory data are available for this subject."
)
)
...
})
This is better than displaying a cryptic R error to the reviewer.
Subject-Level Dashboards
A subject-level dashboard can combine several clinically relevant elements.
For example:
Displaying Subject Summary Cards
A summary card can be rendered using ordinary Shiny outputs.
output$subject_treatment <- renderText({
req(subject_dm())
subject_dm()$TRT01P[1]
})
The corresponding UI can be:
verbatimTextOutput( "subject_treatment" )
For more polished interfaces, dashboard-oriented packages can provide cards, boxes, tabs, and other components.
Tabs Are Useful for Domain Separation
Instead of placing every table on one screen, tabs can organize information.
tabsetPanel(
tabPanel(
"Subject",
...
),
tabPanel(
"Laboratory",
...
),
tabPanel(
"Adverse Events",
...
),
tabPanel(
"Exposure",
...
),
tabPanel(
"Vital Signs",
...
),
tabPanel(
"Data Quality",
...
)
)
This makes the application easier to navigate and reduces information density.
Clinical Data Quality Checks
Shiny can also serve as a front end for data-quality checks.
Examples include:
- Missing required dates
- Impossible date sequences
- Unexpected treatment assignments
- Laboratory values outside expected ranges
- Duplicate observations
- Missing visits
- Inconsistent subject identifiers
- Unexpected treatment exposure
Example Date Consistency Check
Suppose a treatment start date should not occur after an adverse-event start date for an event recorded as occurring before treatment.
A review flag might be created as:
x <- x %>%
mutate(
DATE_REVIEW_FLAG =
AE_START < TRT_START
)
The exact logic depends on the clinical meaning of the variables.
Detecting Duplicate Records
A simple exploratory duplicate check can use a combination of key variables.
duplicates <- data %>%
group_by(
USUBJID,
VISIT,
LBTEST
) %>%
filter(
n() > 1
) %>%
ungroup()
The important question is not merely whether duplicates exist, but whether they are legitimate according to the domain structure.
Missing Visit Review
For longitudinal clinical data, the application can compare observed visits with expected visits.
For example:
expected_visits <-
c(
"Baseline",
"Week 4",
"Week 8",
"Week 12",
"Week 16"
)
The application can then identify expected visits that are absent.
Interactive Plots With plotly
Static ggplot2 graphics can be converted to
interactive Plotly graphics.
library(plotly)
output$lab_plot <- renderPlotly({
p <- ggplot(
subject_lb(),
aes(
x = ADY,
y = LBSTRESN,
text = paste(
"Visit:",
AVISIT,
"<br>Value:",
LBSTRESN
)
)
) +
geom_line() +
geom_point()
ggplotly(
p,
tooltip = "text"
)
})
This can allow reviewers to hover over measurements and inspect exact values.
Why Interactivity Helps Clinical Review
Consider a plot containing 100 laboratory measurements.
A static image forces the reviewer to estimate which point corresponds to which visit.
An interactive plot can expose:
- Exact value
- Study day
- Visit
- Laboratory test
- Reference range
- Subject identifier
This can significantly improve exploratory review.
Adding a Patient-Level Tumor Burden Plot
For oncology studies, a Shiny application can combine clinical data review with longitudinal tumor-burden visualization.
output$tumor_plot <- renderPlot({
x <- tumor_data() %>%
filter(
USUBJID == input$subject
)
ggplot(
x,
aes(
x = ADY,
y = PCHG
)
) +
geom_line() +
geom_point() +
geom_hline(
yintercept = 0
) +
geom_hline(
yintercept = -30,
linetype = "dashed"
) +
labs(
x = "Study Day",
y = "Percent Change from Baseline"
) +
theme_classic()
})
This type of visualization can help a reviewer connect quantitative longitudinal measurements with other subject-level information.
Downloading Filtered Data
Reviewers may need to export the observations currently being displayed.
Shiny provides downloadHandler() for this purpose.
output$download_data <-
downloadHandler(
filename = function() {
paste0(
"clinical_review_",
Sys.Date(),
".csv"
)
},
content = function(file) {
write.csv(
review_data(),
file,
row.names = FALSE
)
}
)
The UI can contain:
downloadButton( "download_data", "Download Review Data" )
Downloads Need Governance
The ability to download clinical data is powerful and potentially sensitive.
A production application should define:
- Who can download data
- Which data may be downloaded
- Whether direct identifiers are present
- Whether exports are logged
- How downloaded files are stored
- How long exports are retained
- Whether exports contain derived review flags
Handling Subject Identifiers
Clinical applications commonly use subject identifiers such as:
USUBJID SUBJID SUBJECT_ID
The application should use a consistent identifier throughout the reactive workflow.
For example:
subject_data <- reactive({
req(input$subject)
list(
dm = dm %>%
filter(
USUBJID == input$subject
),
lb = lb %>%
filter(
USUBJID == input$subject
),
ae = ae %>%
filter(
USUBJID == input$subject
)
)
})
Modularizing a Large Shiny Application
A small application can live in one app.R file.
A large clinical application should generally be modularized.
A possible structure is:
clinical-review-app/
app.R
R/
mod_overview.R
mod_subject.R
mod_labs.R
mod_ae.R
mod_exposure.R
mod_data_quality.R
data/
dm.rds
lb.rds
ae.rds
www/
styles.css
tests/
testthat/
test-review-flags.R
test-date-rules.R
This separates application modules from data, styling, and tests.
Shiny Modules
A module usually has a UI function and a server function.
mod_labs_ui <- function(id) {
ns <- NS(id)
tagList(
selectInput(
ns("parameter"),
"Laboratory Parameter",
choices = NULL
),
DTOutput(
ns("table")
)
)
}
The corresponding server function might be:
mod_labs_server <-
function(
id,
data
) {
moduleServer(
id,
function(
input,
output,
session
) {
...
}
)
}
Why Modules Matter
Modules provide several advantages:
- Reduced code duplication
- Clear separation of responsibilities
- Easier testing
- Easier maintenance
- Smaller server functions
- Better collaboration among programmers
For a clinical review application containing multiple domains, modularity quickly becomes important.
Separating Clinical Logic From UI Logic
A common mistake is placing complex clinical derivations directly inside render functions.
For example, avoid building a large clinical algorithm inside:
output$table <- renderDT({
# hundreds of lines
# of clinical logic
})
Instead, calculate the review dataset separately.
review_labs <- reactive({
...
})
output$table <- renderDT({
datatable(
review_labs()
)
})
This makes the logic easier to inspect and test.
Functions for Reusable Clinical Logic
Even better, some logic can be implemented as ordinary R functions.
flag_high_lab <- function(
value,
upper_limit
) {
!is.na(value) &&
!is.na(upper_limit) &&
value > upper_limit
}
The Shiny server can then call the function.
review_labs <- reactive({
x <- subject_lb()
x$HIGH_FLAG <- mapply(
flag_high_lab,
x$LBSTRESN,
x$LBORNRHI
)
x
})
This is easier to unit-test than logic embedded exclusively in the UI.
Testing Clinical Review Functions
A function such as:
flag_high_lab( value = 86, upper_limit = 40 )
should return:
TRUE
while:
flag_high_lab( value = 35, upper_limit = 40 )
should return:
FALSE
A unit-testing framework such as testthat can
automate these checks.
test_that(
"high laboratory values are flagged",
{
expect_true(
flag_high_lab(86, 40)
)
expect_false(
flag_high_lab(35, 40)
)
}
)
Testing Shiny Applications
Testing should not stop at the underlying R functions.
Shiny provides tools for testing reactive behavior and application outputs.
Useful testing levels include:
| Testing Level | Question |
|---|---|
| Unit test | Does a clinical derivation return the correct value? |
| Reactive test | Does an input produce the expected filtered data? |
| UI test | Does the expected control or output exist? |
| Integration test | Do several application components work together? |
| User acceptance test | Can the intended reviewer complete the required workflow? |
Clinical Validation Versus Software Testing
These concepts overlap but are not identical.
Software testing asks whether the application behaves according to its specification.
Clinical validation asks whether the implemented review logic appropriately represents the intended clinical meaning.
Handling Missing Data
Missingness is common in clinical data.
The application should distinguish between:
- Missing because not collected
- Missing because not applicable
- Missing because subject discontinued
- Missing because of an invalid result
- Missing because data are not yet available
A generic NA does not necessarily explain the
reason for missingness.
Use Clinical Status Variables Where Available
For example:
LBSTAT LBREASND AEOUT DSDECOD EPOCH VISIT
The application can display these variables alongside missing or incomplete observations when appropriate.
Performance Considerations
Clinical datasets can become large.
An application that works well with 10,000 observations may become slow with millions of observations.
Potential strategies include:
- Precompute stable derivations
- Load only necessary variables
- Filter data before expensive operations
- Use database queries for large datasets
- Avoid unnecessary reactive dependencies
- Cache expensive calculations where appropriate
- Use server-side table processing for large listings
Reactive Expressions Should Be Narrow
Suppose a single reactive expression calculates every domain:
all_data <- reactive({
# process DM
# process LB
# process AE
# process EX
# process VS
# process ECG
# process tumor data
})
A small change to one input could cause unnecessary work.
It is usually better to create focused reactive expressions with clear dependencies.
Caching
Some computations are expensive but produce the same result for repeated inputs.
Caching can reduce repeated computation.
The appropriate strategy depends on the application architecture and the specific Shiny version and packages being used.
Security Is Part of the Application Design
A clinical review application may contain confidential trial information.
Security should therefore be considered before deployment rather than added afterward.
Important areas include:
- Authentication
- Authorization
- Encryption in transit
- Secure storage
- Session management
- Download permissions
- Logging
- Infrastructure security
- Secret management
Authentication and Authorization Are Different
Authentication answers:
Authorization answers:
A user successfully logging in does not necessarily mean that the user should have access to every study, treatment arm, subject, or download function.
Do Not Put Security Logic Only in the UI
Hiding a button does not necessarily constitute access control.
For example, this:
if (user_is_admin) {
downloadButton(
"download_data"
)
}
may change the interface, but authorization must also be enforced at the server or application infrastructure level.
Auditability
For a controlled clinical workflow, it may be important to know:
- Who accessed the application
- When they accessed it
- Which study they reviewed
- Which subject was selected
- Which filters were applied
- Which outputs were downloaded
- Which application version was used
The appropriate level of logging depends on the intended use and governance requirements.
Application Versioning
Every production application should have an identifiable version.
For example:
APP_VERSION <- "1.4.0"
The version can be displayed in the UI:
tags$small(
paste(
"Application version:",
APP_VERSION
)
)
Version control systems such as Git are particularly useful for maintaining the history of application changes.
Data Versioning Matters Too
The same application can produce different review results when the underlying clinical data change.
Therefore, a robust review workflow should consider both:
- Application version
- Data snapshot/version
For example:
| Item | Example |
|---|---|
| Application | v1.4.0 |
| Clinical data snapshot | 2026-08-31 |
| Review population | Safety population |
| Study | ABC-001 |
Separating Development, Test, and Production
A useful deployment model is:
Production should not simply be the programmer's current working copy.
Using Configuration Files
Different environments may require different data locations or settings.
These should generally be separated from application logic.
For example:
config <- config::get() data_path <- config$data_path
This can reduce the need to edit application code when moving between environments.
Logging and Diagnostics
A production application should provide enough diagnostic information to identify technical failures.
However, logs should be designed carefully so that they do not unnecessarily expose sensitive clinical information.
Building a Complete Minimal Clinical Review App
The following example combines several concepts into a small but realistic Shiny application.
library(shiny)
library(dplyr)
library(DT)
library(ggplot2)
dm <- readRDS("dm.rds")
lb <- readRDS("lb.rds")
ui <- fluidPage(
titlePanel(
"Clinical Data Review"
),
sidebarLayout(
sidebarPanel(
selectInput(
"treatment",
"Treatment",
choices = c(
"All",
sort(unique(dm$TRT01P))
)
),
selectInput(
"subject",
"Subject",
choices = NULL
),
downloadButton(
"download",
"Download"
)
),
mainPanel(
tabsetPanel(
tabPanel(
"Subject",
DTOutput("subject_table")
),
tabPanel(
"Laboratory",
DTOutput("lab_table"),
plotOutput("lab_plot")
)
)
)
)
)
server <- function(
input,
output,
session
) {
filtered_dm <- reactive({
if (
input$treatment == "All"
) {
dm
} else {
dm %>%
filter(
TRT01P == input$treatment
)
}
})
observe({
updateSelectInput(
session,
"subject",
choices =
sort(
unique(
filtered_dm()$USUBJID
)
)
)
})
subject_dm <- reactive({
req(input$subject)
filtered_dm() %>%
filter(
USUBJID == input$subject
)
})
subject_lb <- reactive({
req(input$subject)
lb %>%
filter(
USUBJID == input$subject
)
})
output$subject_table <-
renderDT({
datatable(
subject_dm(),
rownames = FALSE
)
})
output$lab_table <-
renderDT({
datatable(
subject_lb(),
rownames = FALSE,
filter = "top"
)
})
output$lab_plot <-
renderPlot({
x <- subject_lb()
validate(
need(
nrow(x) > 0,
"No laboratory data available."
)
)
ggplot(
x,
aes(
x = ADY,
y = LBSTRESN
)
) +
geom_line() +
geom_point() +
labs(
x = "Study Day",
y = "Laboratory Value"
) +
theme_classic()
})
output$download <-
downloadHandler(
filename = function() {
paste0(
input$subject,
"_clinical_review.csv"
)
},
content = function(file) {
write.csv(
subject_lb(),
file,
row.names = FALSE
)
}
)
}
shinyApp(
ui,
server
)
Improving the Minimal Application
The example above demonstrates the architecture, but a production-quality application would typically add:
- Input validation
- Better error messages
- Authentication
- Authorization
- Application version information
- Data version information
- Clinical review flags
- Domain-specific modules
- Automated testing
- Controlled downloads
- Performance monitoring
Using shinyvalidate
Input validation can be useful when the reviewer must make a valid selection before proceeding.
For example, a subject selector should not silently accept an invalid value.
Validation packages and ordinary Shiny validation functions can be used depending on the complexity of the application.
Using updateSelectInput()
Dynamic controls are especially useful in clinical review.
For example, the available subjects can depend on the selected treatment.
observeEvent(
input$treatment,
{
choices <- filtered_dm() %>%
pull(USUBJID) %>%
unique() %>%
sort()
updateSelectInput(
session,
"subject",
choices = choices
)
}
)
This prevents the reviewer from selecting a subject that is outside the current treatment filter.
observe() Versus observeEvent()
These functions serve related but different purposes.
| Function | Typical Use |
|---|---|
observe() |
Respond to changes in reactive dependencies |
observeEvent() |
Respond specifically to an event such as a button click or input change |
For example:
observeEvent(
input$refresh,
{
...
}
)
This is useful when the reviewer explicitly requests a refresh or recalculation.
Action Buttons
An action button can allow a reviewer to deliberately initiate a workflow.
actionButton( "refresh", "Refresh Review" )
The server can respond with:
observeEvent(
input$refresh,
{
# perform requested action
}
)
However, many Shiny outputs should update automatically through reactivity. Action buttons should therefore be used when explicit user initiation has meaning.
Clinical Review Workflow With an Action Button
For example:
Don't Hide the Underlying Data
A review application should make it easy to move from a flag to the observations that produced it.
For example, instead of simply displaying:
FLAG: Laboratory abnormality
the application should allow the reviewer to inspect:
- Subject
- Test
- Visit
- Study day
- Result
- Units
- Reference range
- Collection date
- Source status
This makes the flag auditable and clinically interpretable.
Displaying a Review Explanation
A useful application can display the reason a record was flagged.
x$REVIEW_REASON <-
ifelse(
x$LBSTRESN > x$LBORNRHI,
"Result exceeds upper reference limit",
""
)
The reviewer then sees both the flag and the reason.
Flagging Multiple Conditions
A subject may have several types of potential issues.
x <- x %>%
mutate(
HIGH_LAB =
LBSTRESN > LBORNRHI,
LOW_LAB =
LBSTRESN < LBORNRLO,
MISSING_UNIT =
is.na(LBSTRESU),
MISSING_DATE =
is.na(LBDTC)
)
A summary flag can then be derived:
x$ANY_REVIEW_FLAG <- x$HIGH_LAB | x$LOW_LAB | x$MISSING_UNIT | x$MISSING_DATE
Use a Flag Hierarchy
Not all flags have equal importance.
A useful review interface may distinguish:
| Level | Example |
|---|---|
| Informational | Value outside a typical display range |
| Review | Unexpected laboratory abnormality |
| High priority | Potential serious safety-data inconsistency |
The exact classification should be based on the intended clinical workflow.
Data Review Versus Data Cleaning
A Shiny review application can identify potential issues without necessarily changing the underlying data.
For example:
This is different from:
Deployment Options
A Shiny application can be deployed in several environments.
Examples include:
- Local R sessions
- Internal servers
- Shiny Server environments
- Managed Shiny hosting
- Containerized infrastructure
- Enterprise cloud environments
The appropriate option depends on organizational security, data location, authentication, scalability, and governance requirements.
Local Development
For development, the simplest workflow is often:
shiny::runApp( "clinical-review-app" )
This is appropriate for development and testing, not necessarily for production deployment.
Containerization
A larger application may be packaged using a container.
A simplified conceptual Dockerfile might look like:
FROM rocker/r-ver:latest
WORKDIR /app
COPY . /app
RUN R -e "install.packages(
c('shiny','dplyr','DT','ggplot2'),
repos='https://cloud.r-project.org'
)"
EXPOSE 3838
CMD ["R", "-e",
"shiny::runApp(
'/app',
host='0.0.0.0',
port=3838
)"]
The exact base image, package versions, system dependencies, and deployment configuration should be controlled for the intended environment.
Package Version Management
Reproducibility is especially important for clinical applications.
Consider an application that uses:
shiny dplyr DT ggplot2 plotly
Changes in package versions can sometimes affect application behavior.
A dependency-management approach such as renv can
help capture the R package environment.
renv::init() renv::snapshot() renv::restore()
This allows a defined package environment to be recreated more reliably.
Reproducibility Checklist
Common Shiny Performance Problems
Reading Data Inside Every Reactive Expression
This is inefficient:
labs <- reactive({
readRDS(
"lb.rds"
)
})
If the underlying data do not change during the session, they can generally be loaded outside the reactive expression.
lb <- readRDS(
"lb.rds"
)
labs <- reactive({
lb %>%
filter(
USUBJID == input$subject
)
})
Repeatedly Performing Expensive Joins
If several outputs repeatedly perform the same expensive join, consider preparing the required review dataset once.
review_base <- reactive({
dm %>%
left_join(
treatment,
by = "USUBJID"
)
})
Then downstream outputs can use the result.
Common Clinical Shiny Mistakes
- Building one enormous app.R file. Large applications become difficult to test and maintain.
- Putting clinical logic directly inside render functions. This makes derivations harder to validate.
- Modifying source data in place. Review flags should generally be derived separately.
- Assuming a flag equals an error. A flagged record is a review candidate.
- Ignoring reference-range metadata. Clinical laboratory limits may differ by laboratory and context.
- Hard-coding credentials. Secrets should be managed securely.
- Using UI visibility as authorization. Hiding a button is not sufficient access control.
- Failing to version the application. Review outputs should be traceable to the application version.
- Failing to version the data. The same application can produce different results from different data snapshots.
- Skipping independent validation. Interactive applications still require testing.
Clinical Data Review Application Specification
Before programming, define the intended application behavior.
| Specification | Example |
|---|---|
| Purpose | Interactive exploratory clinical data review |
| Population | All randomized subjects |
| Data snapshot | Defined clinical database extract |
| Primary identifier | USUBJID |
| Filters | Treatment, site, subject, visit |
| Domains | DM, LB, AE, EX, VS |
| Flags | Predefined review rules |
| Plots | Laboratory and longitudinal clinical measurements |
| Downloads | Filtered review dataset |
| Authentication | Required for controlled deployment |
| Version | Displayed within application |
Example Application Requirements
A more formal requirement might say:
This is much more useful than a vague requirement such as "build a dashboard."
User Acceptance Testing
A clinical review application should have explicit test scenarios.
| Test | Expected Result |
|---|---|
| Select Drug A | Only Drug A subjects appear |
| Select Site 101 | Only Site 101 subjects appear |
| Select subject 1001 | All subject-level domains show 1001 |
| Select laboratory parameter | Corresponding laboratory records appear |
| Subject has no AE records | Application displays an appropriate empty-state message |
| Flagged laboratory result exists | Expected review flag is displayed |
| Download selected data | Downloaded file matches displayed filtered data |
Empty States Matter
An application should explicitly communicate when there is no data.
For example:
validate(
need(
nrow(x) > 0,
"No adverse events are available for this subject."
)
)
This is much better than showing an empty table with no explanation.
Error Handling
Unexpected technical problems should be separated from expected clinical conditions.
| Situation | Preferred Behavior |
|---|---|
| No subject selected | Prompt for a subject |
| No laboratory data | Display informative empty state |
| Invalid data structure | Generate controlled technical error |
| Database unavailable | Display service-unavailable message and log technical details appropriately |
| Unexpected application exception | Fail safely without exposing sensitive internals |
Clinical Review and Traceability
One of the strongest features of a well-designed application is the ability to trace a displayed result back to its underlying data.
For example:
This principle is particularly important when the application is used in a clinical-development environment.
Example Traceability Structure
| Displayed Item | Source / Derivation |
|---|---|
| Treatment | DM treatment variable |
| Subject status | Disposition-derived value |
| ALT flag | LB result compared with applicable reference limit |
| SAE count | AE records with serious-event indicator |
| Laboratory trend | Longitudinal LB observations |
Shiny for Exploratory Clinical Statistics
Although clinical data review is the focus of this tutorial, Shiny can also support exploratory statistical analysis.
Examples include:
- Descriptive summaries
- Box plots
- Longitudinal plots
- Patient-level profiles
- Response summaries
- Subgroup exploration
- Laboratory shift tables
- Exposure-response exploration
Example Laboratory Shift Summary
A Shiny application can generate a simple baseline-versus-post-baseline summary.
shift_data <- reactive({
x <- subject_lb()
x %>%
mutate(
SHIFT = case_when(
BASELINE == "Normal" &
POSTBASE == "High"
~ "Normal → High",
BASELINE == "High" &
POSTBASE == "Normal"
~ "High → Normal",
TRUE
~ "Other"
)
)
})
The exact definition of baseline and post-baseline status should follow the study's analysis conventions.
Using Shiny With ADaM Data
A clinical review application can also consume analysis-ready ADaM datasets.
For example:
- ADSL for subject-level information
- ADLB for laboratory analysis
- ADAE for adverse-event analysis
- ADVS for vital signs
- ADTTE for time-to-event review
This can be particularly useful when the application is intended to review analysis populations or derived endpoints.
Using Shiny With SDTM Data
For source-oriented review, SDTM domains may provide useful standardized structures.
For example:
dm <- readRDS("sdtm_dm.rds")
lb <- readRDS("sdtm_lb.rds")
ae <- readRDS("sdtm_ae.rds")
The application can expose domain-specific variables while preserving their SDTM meaning.
Using Shiny With ADaM Data
For analysis review:
adsl <- readRDS("adsl.rds")
adlb <- readRDS("adlb.rds")
adae <- readRDS("adae.rds")
The application can then use analysis variables such as treatment, population flags, baseline indicators, change from baseline, and analysis values.
Don't Mix SDTM and ADaM Variables Without Explaining the Difference
A reviewer should be able to determine whether a displayed value represents:
- A collected clinical observation
- A standardized SDTM value
- An analysis derivation
- A review-specific flag
Clear labeling is therefore essential.
Clinical Review App Versus CSR Output
| Feature | Clinical Review App | CSR Output |
|---|---|---|
| Interactivity | High | Usually limited |
| Filtering | User controlled | Prespecified |
| Exploration | Excellent | Limited |
| Traceability | Can be interactive | Usually static/documented |
| Primary analysis | Usually not | Yes, when applicable |
| Clinical review | Excellent | Limited |
When Shiny Is a Good Choice
Shiny is particularly useful when:
- Reviewers need to explore many subjects.
- Multiple clinical domains need to be connected.
- Interactive filtering provides substantial value.
- Longitudinal trends need exploration.
- Users need patient-level drill-down.
- Repeated static report generation is inefficient.
When Shiny May Not Be the Best Choice
A Shiny application may be unnecessary when:
- A single static table completely answers the question.
- The output is a formal submission deliverable.
- The workflow requires a tightly controlled validated reporting system rather than exploratory review.
- The dataset is too large for the chosen architecture without an appropriate data backend.
- The intended users do not need interactive functionality.
Production Readiness Checklist
A Practical Development Workflow
A sensible development sequence is:
- Start with one domain.
- Build the subject selector.
- Build one interactive table.
- Add one longitudinal plot.
- Add clinical review flags.
- Separate clinical logic into functions.
- Add automated tests.
- Introduce modules.
- Add authentication and authorization.
- Optimize performance.
- Deploy to a controlled test environment.
- Perform user acceptance testing.
- Release the approved version.
Trying to build the entire clinical review platform at once often creates unnecessary complexity.
Example Project Structure
clinical-review/ ├── app.R │ ├── R/ │ ├── mod_overview.R │ ├── mod_subject.R │ ├── mod_labs.R │ ├── mod_ae.R │ ├── mod_exposure.R │ └── review_rules.R │ ├── data/ │ ├── adsl.rds │ ├── adlb.rds │ ├── adae.rds │ └── adex.rds │ ├── tests/ │ └── testthat/ │ ├── test-review-rules.R │ └── test-derived-flags.R │ ├── www/ │ └── custom.css │ ├── renv.lock │ └── README.md
Documentation Should Be Part of the Application
A production clinical review application should have documentation describing:
- Purpose
- Intended users
- Data sources
- Data refresh process
- Review rules
- Known limitations
- Application version
- Deployment architecture
- Security model
- Testing strategy
What a README Should Explain
# Clinical Review Application ## Purpose Interactive review of clinical-trial data. ## Data Sources ADSL, ADLB, ADAE, and related analysis datasets. ## Application Shiny application implemented in R. ## Review Flags Defined in R/review_rules.R. ## Testing Automated tests are stored under tests/testthat/. ## Version 1.4.0 ## Data Snapshot 2026-08-31
Clinical Review App Design Principles
Several principles are particularly important.
| Principle | Meaning |
|---|---|
| Traceability | Users can identify where displayed values originate. |
| Transparency | Review flags have understandable explanations. |
| Reproducibility | Application and data versions are identifiable. |
| Security | Users only access information appropriate to their role. |
| Validation | Clinical and technical behavior is tested. |
| Separation | Source data, derivations, presentation, and review actions remain distinguishable. |
| Usability | Reviewers can reach the relevant subject-level information efficiently. |
The Most Important Shiny Concept
The most important concept is not a particular widget or plotting package.
It is the reactive dependency graph.
For a clinical review application, the graph might look like:
Once this dependency structure is understood, the rest of Shiny becomes much more manageable.
The Most Important Clinical Concept
The most important clinical concept is that the application is a review layer, not an excuse to obscure the underlying data.
Every important displayed result should have a clear explanation of:
- What the value represents
- Where it came from
- How it was derived
- Why it was flagged
- What the reviewer should do with the information
Recommended Mental Model
Final Checklist
Summary
R Shiny provides a powerful framework for turning clinical-trial data into an interactive review environment.
The basic architecture is straightforward:
The challenge in clinical applications is not simply creating an attractive interface. The application must preserve the meaning, provenance, and traceability of clinical data while making review faster and more intuitive.
A well-designed application can allow a reviewer to move from study-level summaries to treatment groups, subjects, individual laboratory results, adverse events, exposure records, and longitudinal plots without repeatedly generating static listings.
The most effective architecture separates:
- Source data
- Clinical derivation logic
- Review rules
- Reactive application logic
- User interface
- Security
- Testing
- Deployment
When these layers are kept distinct, Shiny becomes much more than an R dashboard. It becomes a flexible framework for interactive clinical-data review.
References
Chang, W., Cheng, J., Allaire, J., Xie, Y., McPherson, J.
shiny: Web Application Framework for R.
R package documentation.
Wickham, H., Averick, M., Bryan, J., et al. (2019).
Welcome to the tidyverse.
Journal of Open Source Software, 4(43), 1686.
Wickham, H., François, R., Henry, L., Müller, K., Vaughan, D.
dplyr: A Grammar of Data Manipulation.
R package documentation.
Xie, Y., Cheng, J., Allaire, J.J.
DT: A Wrapper of the JavaScript Library DataTables.
R package documentation.
Wickham, H.
ggplot2: Elegant Graphics for Data Analysis.
Springer.
Posit.
Shiny Documentation.
Documentation for building, testing, and deploying Shiny applications.
R Core Team.
R: A Language and Environment for Statistical Computing.
R Foundation for Statistical Computing.