install.packages("R6")
library(R6)34 R6
Shiny apps accumulate complexity in two places: the UI tends to get wide; the server tends to get deep. In large apps the server can become a tangle of reactive values, observers, and helper functions all sharing mutable state through reactive value objects passed around as implicit globals.
Packaging apps as an R package helps us with tooling, but {R6} classes can help us with architecture. Together they let us draw a clean line between “things that change at runtime” and “things that handle reactivity.” As a bonus, we can test each side independently.
In this chapter, we’ll walk through movexplR6, a Shiny app-package that lets users explore movie ratings from a SQLite database.1
We’ll cover:
- What
{R6}is, reference semantics, and mutable states - Using an
{R6}class for database access, filtering, and cleanup
- Package structure (where
{R6}classes live and how to document them)
- How the app server wires
{R6}classes into the reactive graph
- Composing modules that work with
{R6}classes
- Testing
{R6}(independent of shiny)
34.1 What Is {R6}?
R has several object-oriented systems, and I’ve found that choosing the right one matters for Shiny architecture.
S3 and S4 use copy-on-modify semantics: when we pass an object to a function and modify it inside, we modify a copy and the original is unchanged.
{R6}uses reference semantics: all variables that point to the same{R6}object share the same underlying data. Modifying the object through one variable is immediately visible through all other variables that reference it.
In Shiny apps, reference semantics are exactly what we want for mutable state. Database connections, cached datasets, log buffers, etc. are objects that need to change in place rather than produce modified copies. {R6} is also faster than R5 (Reference Classes), has a cleaner API, and integrates well with testthat.
34.2 The App at a Glance
movexplR6 has one R6 class, two Shiny modules, and a thin server/UI layer that connects them. The diagram below shows how the pieces fit together.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"15px"}}}%%
flowchart TD
subgraph Pkg["<strong>movexplR6 package</strong>"]
MovData["<code>MovieData</code><br>(R6 class)<br>R/MovieData.R"]
FilMod("<code>mod_filters</code><br>R/mod_filters.R")
PltMod("<code>mod_plot</code><br>R/mod_plot.R")
MovServ("<code>movies_server()</code><br>R/movies_server.R")
MovUI("<code>movies_ui()</code><br>R/movies_ui.R")
end
DB[("SQLite<br>movies.db")]
MovData -->|"opens connection"| DB
MovServ -->|"instantiates<br>MovieData$new()"| MovData
MovServ -->|"calls"| FilMod
MovServ -->|"calls"| PltMod
FilMod -->|"returns<br>filters() reactive"| MovServ
MovServ -->|"returns<br>movies() reactive"| PltMod
MovUI -->|"renders<br>mod_filters_ui()"| FilMod
MovUI -->|"renders<br>mod_plot_ui()"| PltMod
style MovData fill:#E8F5E9,color:#1B5E20,stroke:#4CBB9D,stroke-width:2px
style MovServ fill:#E3F2FD,color:#0D47A1,stroke:#1976D2,stroke-width:2px
style FilMod fill:#F3E5F5,color:#4A148C,stroke:#7B1FA2,stroke-width:1px
style PltMod fill:#F3E5F5,color:#4A148C,stroke:#7B1FA2,stroke-width:1px
style MovUI fill:#F3E5F5,color:#4A148C,stroke:#7B1FA2,stroke-width:1px
style DB fill:#FFF3E0,color:#E65100,stroke:#FF6F00,stroke-width:2px
style Pkg fill:#FAFAFA,color:#424242,stroke:#BDBDBD,stroke-width:1px,stroke-dasharray: 5 5
{R6} manages the database connection and filtering logic, while the Shiny modules handle the UI and reactive wiring.
The key insight is that MovieData sits outside the reactive graph entirely. MovieData knows nothing about what’s going on with Shiny, it just does data stuff (i.e., connects to the SQL database, holds the data, and filters it on demand). The server function is the adapter between {R6} and reactivity.
34.3 The MovieData R6 Class
MovieData is the heart of the app. It owns the database connection, loads the full joined dataset into memory on initialization, and exposes a single filter() method that returns a filtered data frame on demand.
Below is the definition of the MovieData object:
MovieData class definition (R/MovieData.R)
# location: R/MovieData.R
MovieData <- R6::R6Class(
"MovieData",
public = list(
con = NULL,
all_movies = NULL,
initialize = function(db_path) {
self$con <- DBI::dbConnect(
RSQLite::SQLite(),
db_path
)
logger::log_info("Connected to {db_path}")
tryCatch(
private$load_data(),
error = function(e) {
logger::log_error("load_data() failed: {e$message}")
stop(e)
}
)
},
filter = function(
reviews = 10,
oscars = 0,
year = c(1940, 2014),
boxoffice = c(0, 800),
genre = "All",
director = "",
cast = ""
) {
df <- self$all_movies
df <- df[df$Reviews >= reviews & df$Oscars >= oscars, ]
df <- df[df$Year >= year[1] & df$Year <= year[2], ]
df <- df[
df$BoxOffice >= boxoffice[1] * 1e6 &
df$BoxOffice <= boxoffice[2] * 1e6, ]
if (genre != "All") df <- df[df$Genre == genre, ]
if (nchar(director) > 0)
df <- df[grepl(director, df$Director, ignore.case = TRUE), ]
if (nchar(cast) > 0)
df <- df[grepl(cast, df$Cast, ignore.case = TRUE), ]
df$has_oscar <- ifelse(df$Oscars >= 1, "Yes", "No")
logger::log_debug("{nrow(df)} rows after filter")
df
},
disconnect = function() {
if (DBI::dbIsValid(self$con)) {
DBI::dbDisconnect(self$con)
logger::log_info("Database disconnected")
}
}
),
private = list(
finalize = function() self$disconnect(),
load_data = function() {
omdb <- dplyr::tbl(self$con, "omdb")
tomatoes <- dplyr::tbl(self$con, "tomatoes")
self$all_movies <- omdb |>
dplyr::inner_join(tomatoes, by = "ID") |>
dplyr::filter(Reviews >= 10) |>
dplyr::select(
ID, Title, Year, Runtime, Genre, Director, Cast,
BoxOffice, Oscars, imdbRating, imdbVotes,
Meter, Rating = Rating.y, Reviews, Fresh, Rotten,
userMeter, userRating, userReviews
) |>
dplyr::collect()
logger::log_info("{nrow(self$all_movies)} movies loaded")
}
)
)- 1
-
R6::R6Class()takes the class name as its first argument; this name is used byinherits()checks and in error messages. - 2
-
initialize()runs when you callMovieData$new(db_path). It opens the connection and immediately loads data; any error duringload_data()is logged and re-thrown so the caller knows construction failed. - 3
-
filter()operates onall_moviesin memory; no additional database queries happen after initialization.boxofficeis in millions in the UI but raw dollars in the data, so the method scales before filtering. - 4
-
disconnect()guards against callingDBI::dbDisconnect()on an already-closed connection — important becausefinalize()andshiny::onStop()may both attempt cleanup. - 5
-
finalize()is{R6}’s garbage-collection hook; it is called when the object is garbage-collected. In practiceonStop()disconnects first, butfinalize()is a safety net. - 6
-
load_data()is private; callers cannot invoke it directly. It joins two SQLite tables withdbplyr, applies a minimum-reviews pre-filter, then collects the result into a plain data frame that lives in memory for the lifetime of the session.{R6}’s reference semantics mean this singleall_moviesdata frame is shared efficiently across all method calls without copying.
The diagrams below shows the public interface and private internals.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"13px"}}}%%
flowchart TB
subgraph Private["<strong>PRIVATE</strong> (implementation only)<br>"]
LoadData("<code>load_data()</code><br><em>joins tables</em>")
Finalize("<code>finalize()</code><br><em>garbage collection</em>")
end
subgraph Public["<strong>PUBLIC</strong> (safe to call)"]
Con("<code>con</code><br><em>DBIConnection</em>")
AllMovies("<code>all_movies</code><br><em>data.frame</em>")
Init("<code>initialize(db_path)</code><br><em>Constructor</em>")
Filter("<code>filter(...)</code><br><em>returns data.frame</em>")
Disconnect("<code>disconnect()</code><br><em>cleanup</em>")
end
style Private fill:#FFEBEE,color:#B71C1C,stroke:#D32F2F,stroke-width:2px
style Public fill:#E8F5E9,color:#1B5E20,stroke:#4CBB9D,stroke-width:2px
initialize()
The initialize() method runs once when we call MovieData$new(db_path). It opens the database connection and immediately loads all movies into memory. Any error during data loading is caught, logged, and re-thrown so the caller knows construction failed. This ‘fail-fast’ approach prevents silent corruption of state downstream.
A note on filter()
filter() returns an ordinary data.frame, not a reactive. This is intentional and critical for testing. Keeping MovieData free of Shiny dependencies means we can call filter() in unit tests (no testServer() needed), in a script, or in a plumber API without loading Shiny at all. The reactive wrapper lives one layer up in movies_server(), maintaining the clean separation between stateful logic and reactivity that the chapter is built around.
34.4 Wiring MovieData
movies_server() is the adapter between MovieData and Shiny. It enforces the boundary we’ve drawn: {R6} owns the stateful logic and data, Shiny owns the reactivity.
One rule to rule them all
We should never instantiate {R6} objects at the package level (outside a function). Writing movie_data <- MovieData$new(db_path) at the top of an R/ file creates one shared object loaded for every session, polluting the namespace and breaking session isolation. Always instantiate inside server() or a module server function, where each object is scoped to a single user session.
movies_server() creates the {R6} instance once per session, registers cleanup, and wraps filter() in a reactive so the UI can trigger re-filtering when inputs change.
movies_server <- function(input, output, session) {
db_path <- system.file(
"extdata/movies.db",
package = "movexplR6"
)
movie_data <- MovieData$new(db_path)
shiny::onStop(function() movie_data$disconnect())
filters <- mod_filters_server("filters")
movies <- shiny::reactive({
f <- filters()
movie_data$filter(
reviews = f$reviews,
oscars = f$oscars,
year = f$year,
boxoffice = f$boxoffice,
genre = f$genre,
director = f$director,
cast = f$cast
)
})
mod_plot_server("plot", movies, filters)
}- 1
-
system.file()resolves the path to the bundled SQLite database inside the installed package. - 2
-
MovieData$new()runsinitialize(): opens the connection and loads all movies intoall_movies. This happens once per session. - 3
-
shiny::onStop()registers a callback that fires when the session ends (browser closes, user disconnects). This is the primary cleanup path; the privatefinalize()is a backstop in case the session ends unexpectedly. - 4
-
mod_filters_server()returns a reactive list of the current filter values; it re-evaluates whenever any input changes. - 5
-
The
moviesreactive callsmovie_data$filter()with the current filter values wheneverfilters()invalidates. Notice thatmovie_datais captured by reference —{R6}’s reference semantics mean no copying happens here. - 6
-
Both reactives (
moviesandfilters) are passed into the plot module so it can display the filtered count and render axes fromfilters()$xvarandfilters()$yvar.
The reactive data flow looks like this:
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"13px"}}}%%
flowchart TD
subgraph Input["<strong>User Input</strong>"]
UIIn(["input$reviews, input$year<br>input$genre, input$xvar, etc."])
end
subgraph Filters["<strong>Filter Module (Reactive)</strong>"]
FilServ("<code>mod_filters_server()</code>")
FilReact("<code>filters()</code> reactive<br><em>returns list</em>")
end
subgraph State["<strong>R6 State (Non-Reactive)</strong>"]
MovData{{"<code>movie_data$filter()</code><br><em>calls R6 method</em>"}}
end
subgraph Movies["<strong>Movies Reactive</strong>"]
MovReact("<code>movies()</code> reactive<br>wraps R6 output")
end
subgraph Plot["<strong>Plot Module (Reactive)</strong>"]
PltServ("<code>mod_plot_server()</code>")
Scatter(["<code>output$scatter</code>"])
Count(["<code>output$n_movies</code>"])
end
UIIn -->|invalidates| FilServ
FilServ -->|returns| FilReact
FilReact -->|calls with args| MovData
MovData -->|returns data.frame| MovReact
FilReact -->|passed to| PltServ
MovReact -->|passed to| PltServ
PltServ -->|renders| Scatter
PltServ -->|renders| Count
style Input fill:#FFF9C4,color:#000000,stroke:#F1C232,stroke-width:2px
style State fill:#E8F5E9,color:#1B5E20,stroke:#4CBB9D,stroke-width:2px
style Filters fill:#E3F2FD,color:#0D47A1,stroke:#1976D2
style Movies fill:#E3F2FD,color:#0D47A1,stroke:#1976D2
34.5 Module Composition
mod_filters
mod_filters_server() has one job: collect all filter inputs and return them as a single reactive list. By bundling the outputs into one reactive, the downstream consumers can just call filters() once rather than taking individual dependencies on seven separate inputs.
mod_filters_server <- function(id) {
shiny::moduleServer(id, function(input, output, session) {
shiny::reactive({
list(
reviews = input$reviews,
oscars = input$oscars,
year = input$year,
boxoffice = input$boxoffice,
genre = input$genre,
director = input$director,
cast = input$cast,
xvar = input$xvar,
yvar = input$yvar
)
})
})
}- 1
-
The entire return value is one
reactive(). Every element re-evaluates together whenever any input changes, which is simpler than returning areactiveValues()object.
mod_plot
mod_plot_server() receives two reactives and owns both outputs. Accepting movies as a reactive argument (rather than calling movie_data$filter() directly) keeps the module decoupled from the data source. An added bonus is that we can pass any reactive data.frame while testing.
mod_plot_server <- function(id, movies, filters) {
shiny::moduleServer(id, function(input, output, session) {
output$scatter <- plotly::renderPlotly({
f <- filters()
p <- ggplot2::ggplot(
movies(),
ggplot2::aes(
x = .data[[f$xvar]],
y = .data[[f$yvar]],
color = has_oscar,
text = paste0(
Title, " (", Year, ")\n$",
scales::comma(BoxOffice)
)
)
) +
ggplot2::geom_point(alpha = 0.4, size = 2) +
ggplot2::scale_color_manual(
values = c("Yes" = "orange", "No" = "#aaaaaa")
) +
ggplot2::theme_minimal()
plotly::ggplotly(p, tooltip = "text")
})
output$n_movies <- shiny::renderText({
paste("Movies selected:", nrow(movies()))
})
})
}- 1
-
.data[[f$xvar]]uses tidy evaluation’s.datapronoun to select columns by name at runtime;f$xvarandf$yvarcome from thefilters()reactive so the axes update whenever the user changes the axis selectors.
- 2
-
movies()is called again here; because it is the same reactive, Shiny caches the result and does not re-runmovie_data$filter()a second time within a single reactive flush.
Module wiring in the UI
movies_ui() uses bslib::page_sidebar() with the filter module in the sidebar and the plot module in the main content area.
movies_ui <- function() {
bslib::page_sidebar(
title = "Movie Explorer",
sidebar = bslib::sidebar(
mod_filters_ui("filters")
),
mod_plot_ui("plot")
)
}- 1
-
The
"filters"ID here must match the"filters"ID passed tomod_filters_server()inmovies_server(). - 2
-
mod_plot_ui("plot")likewise pairs withmod_plot_server("plot", ...).
34.6 The Session Lifecycle
The sequence below traces a complete user session from browser open to browser close.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"16px"}}}%%
sequenceDiagram
participant Usr as Browser
participant Srv as movies_server()
participant R6 as MovieData
participant DB as SQLite DB
Usr->>Srv: session starts
Srv->>R6: MovieData$new(db_path)
R6->>DB: DBI::dbConnect()
R6->>DB: load_data() — join omdb + tomatoes
DB-->>R6: all_movies (data frame in memory)
R6-->>Srv: movie_data instance
loop User changes filters
Usr->>Srv: input changes
Srv->>R6: movie_data$filter(...)
R6-->>Srv: filtered data frame
Srv-->>Usr: updated plot + count
end
Usr->>Srv: browser closes / session ends
Srv->>R6: onStop fires → movie_data$disconnect()
R6->>DB: DBI::dbDisconnect()
The important observation is that MovieData$new() runs once per session and filter() runs many times. Keeping load_data() in initialize() means the expensive join happens once. Every filter call is an in-memory operation on an already-collected data.frame.
34.7 Package Structure
Organize {R6} classes just like functions, which one class per file in R/, clearly named. This makes the R/ folder easier to navigate and scales cleanly as we add more classes.
movexplR6/
R/
MovieData.R # R6 class
mod_filters.R # filters UI + server module
mod_plot.R # plot UI + server module
movies_ui.R # top-level UI
movies_server.R # top-level server
launch_app.R # shinyApp() wrapper
utils.R # axis_vars vector
inst/
extdata/
movies.db # SQLite database
tests/
testthat/
helper.R
test-MovieData.R
test-mod_filters.R
test-mod_plot.RDocumenting {R6} classes
{R6} classes need the same roxygen2 documentation as functions. Document the class, its fields, and its methods so users understand what they can and cannot do.
Class-level documentation goes before the class definition and should include @description (a brief statement of purpose) and @details (how to use it):
#' MovieData R6 class
#'
#' @description
#' Manages a SQLite database connection and provides filtered access to movie data.
#' Initialize with `MovieData$new(db_path)` to open a connection and load the dataset.
#' Call `$filter()` to retrieve a filtered subset of movies.
#'
#' @details
#' `MovieData` is designed to sit outside the reactive graph. It knows nothing about Shiny;
#' it simply loads data once at initialization and filters it on demand. This design makes
#' it testable in isolation and reusable in non-Shiny contexts (scripts, APIs, etc).
#'
#' The class automatically cleans up the database connection when the Shiny session ends
#' (via `shiny::onStop()`), or when the object is garbage-collected (via the private
#' `finalize()` method).
#'
#' @export
MovieData <- R6::R6Class("MovieData", ...)Field documentation uses the @field tag to document public fields (attributes):
public = list(
#' @field con Active `DBI` database connection.
con = NULL,
#' @field all_movies Full joined and collected data frame.
all_movies = NULL
)Method documentation uses standard @param and @return tags to describe what each method does and what it needs:
#' @description Filter the in-memory data frame using input values.
#' @param reviews Minimum number of Rotten Tomatoes reviews (default: 10).
#' @param oscars Minimum number of Oscar wins (default: 0).
#' @param year Integer vector of length 2: `c(min_year, max_year)` (default: `c(1940, 2014)`).
#' @param boxoffice Numeric vector of length 2: box-office range in millions (default: `c(0, 800)`).
#' @param genre Genre string; `"All"` disables the filter (default: `"All"`).
#' @param director Partial director name; empty string disables the filter (default: `""`).
#' @param cast Partial cast name; empty string disables the filter (default: `""`).
#' @return A filtered data frame with rows matching all criteria and an added `has_oscar` column (`"Yes"` or `"No"`).
filter = function(
reviews = 10,
oscars = 0,
year = c(1940, 2014),
boxoffice = c(0, 800),
genre = "All",
director = "",
cast = ""
) For a complete reference on documenting {R6} classes with roxygen2, see the roxygen2 R6 documentation.
34.8 Testing MovieData
Because MovieData has no reactive internals, so we can test it with plain testthat(i.e., no shinytest2, no testServer(), no running app required).
Without shiny
We can use system.file() to use the actual database in testing, test is class, and that the data is there.
# tests/testthat/test-MovieData.R
db_path <- system.file("extdata/movies.db", package = "movexplR6")
test_that("MovieData initializes and loads data", {
md <- MovieData$new(db_path)
on.exit(md$disconnect())
expect_true(R6::is.R6(md))
expect_s3_class(md$all_movies, "data.frame")
expect_true(nrow(md$all_movies) > 0)
})- 1
-
on.exit()guaranteesdisconnect()runs even if a test assertion fails; this prevents leaked database connections across test files.
We can even test the filter() functionality:
test_that("filter() respects reviews threshold", {
md <- MovieData$new(db_path)
on.exit(md$disconnect())
n_low <- nrow(md$filter(reviews = 10))
n_high <- nrow(md$filter(reviews = 200))
expect_gt(n_low, n_high)
})
test_that("filter() adds has_oscar column", {
md <- MovieData$new(db_path)
on.exit(md$disconnect())
result <- md$filter(oscars = 1)
expect_true("has_oscar" %in% names(result))
expect_true(all(result$has_oscar == "Yes"))
})
test_that("disconnect() closes the connection", {
md <- MovieData$new(db_path)
md$disconnect()
expect_false(DBI::dbIsValid(md$con))
})
test_that("disconnect() is idempotent", {
md <- MovieData$new(db_path)
expect_no_error({
md$disconnect()
md$disconnect()
})
})- 1
-
When
oscars = 1every returned row must have won at least one Oscar, sohas_oscarshould be"Yes"for all. - 2
-
Calling
disconnect()twice must not throw; the internalDBI::dbIsValid()guard handles this.
With shiny
Module tests use shiny::testServer(), which provides a lightweight reactive context without a running browser:
# tests/testthat/test-mod_filters.R
test_that("mod_filters_server returns a reactive list", {
shiny::testServer(mod_filters_server, {
session$setInputs(
reviews = 120, oscars = 2,
year = c(2000, 2010), boxoffice = c(100, 400),
genre = "Action", director = "Nolan",
cast = "", xvar = "BoxOffice", yvar = "Year"
)
f <- session$returned()
expect_equal(f$reviews, 120)
expect_equal(f$genre, "Action")
expect_equal(f$xvar, "BoxOffice")
})
})# tests/testthat/test-mod_plot.R
test_that("mod_plot_server renders movie count", {
md <- MovieData$new(db_path)
on.exit(md$disconnect())
filtered <- md$filter(genre = "Drama")
movies <- shiny::reactive(filtered)
filters <- shiny::reactive(list(xvar = "Meter", yvar = "Reviews"))
shiny::testServer(
mod_plot_server,
args = list(movies = movies, filters = filters),
{
expect_match(output$n_movies, "^Movies selected: [0-9]+$")
expect_equal(
as.integer(sub("Movies selected: ", "", output$n_movies)),
nrow(filtered)
)
}
)
})34.9 Recap
movexplR6 demonstrates a clean division of labor between {R6} and Shiny:
MovieDataowns the database connection and all filtering logic. It is framework-agnostic and fully testable with plaintestthat. Keep{R6}classes like this (one responsibility, no reactive internals).movies_server()is the adapter. It createsMovieDataonce per session, wrapsfilter()in areactive()so the rest of the app can depend on it, and registers cleanup withshiny::onStop().- Modules (
mod_filters,mod_plot) communicate through reactive arguments passed by the server. Neither module knows aboutMovieDatadirectly, they only see a reactive data frame. This decoupling is what makes them independently testable. - Never instantiate
{R6}objects at the package level. Any object created inR/outside a function is shared across all sessions. Create instances insideserver()or module server functions. finalize()is a backstop, not the primary cleanup. Registerdisconnect()withshiny::onStop(); letfinalize()catch anything that slips through.
This pattern scales naturally to apps with multiple data sources or complex business logic. Add more {R6} classes for each domain concern, keep each one free of Shiny dependencies, and wire them into the reactive graph through thin adapter reactives in the server. For simpler apps where state is minimal, reactiveValues() alone may suffice. The {R6} pattern is most valuable when the server logic becomes too tangled to test in isolation.
This app comes from the shiny-examples GitHub repo.↩︎
