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 tangled 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 and why reference semantics matter for Shiny state
- The
MovieDataR6 class: database access, filtering, and cleanup
- Package structure and where R6 classes live
- How
movies_server()wiresMovieDatainto the reactive graph
- Module composition with
mod_filtersandmod_plot
- Testing
MovieDataindependently of Shiny
34.1 What Is R6?
R has several object-oriented systems.
S3 and S4 use copy-on-modify semantics: when you pass an object to a function and modify it inside, you 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.
Reference semantics are exactly what you want for mutable state in a Shiny server. A database connection, a cached dataset, a log buffer; these all 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":"16px"}}}%%
flowchart TD
subgraph Pkg["movexplR6 package"]
MovData["MovieData<br>(R6 class)<br>R/MovieData.R"]
FilMod["mod_filters<br>R/mod_filters.R"]
PltMod["mod_plot<br>R/mod_plot.R"]
MovServ["movies_server()<br>R/movies_server.R"]
MovUI["movies_ui()<br>R/movies_ui.R"]
end
DB[("SQLite<br>movies.db")]
MovData -- "DBI::dbConnect()" --> DB
MovServ -- "MovieData$new()" --> MovData
MovServ -- "mod_filters_server()" --> FilMod
MovServ -- "mod_plot_server()" --> PltMod
FilMod -- "filters() reactive" --> MovServ
MovServ -- "movies() reactive" --> PltMod
MovUI -- "mod_filters_ui()" --> FilMod
MovUI -- "mod_plot_ui()" --> PltMod
R6 manages the database connection and filtering logic; Shiny modules handle the UI and reactive wiring.
The key insight is that MovieData sits outside the reactive graph entirely. It knows nothing about Shiny; it just 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 movexplR6. 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.
The class diagram below shows its public interface and private internals.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"13px"}}}%%
classDiagram
class MovieData {
+DBIConnection con
+data.frame all_movies
+initialize(db_path)
+filter(reviews, oscars, year, boxoffice, genre, director, cast)
+disconnect()
-load_data()
-finalize()
}
34.3.1 initialize(): connecting and loading
34.3.2 A note on filter() returning plain data
filter() returns an ordinary data.frame, not a reactive. This is intentional; keeping MovieData free of Shiny dependencies means you can call filter() in unit tests, in a script, or in a plumber API without loading Shiny at all. The reactive wrapper lives one layer up in movies_server().
34.4 Package Structure
Place each R6 class definition in R/ with a descriptive file name. One class per file keeps things easy to navigate.
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.RAdd a roxygen2 @export tag so the class is available to users of the package:
#' MovieData R6 class
#'
#' Manages the SQLite connection and filtered movie dataset.
#'
#' @export
MovieData <- R6::R6Class("MovieData", ...)One rule that matters: 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. Always instantiate inside server() or a module server function, where the object is scoped to a single user session.
34.5 Wiring MovieData to the Reactive Graph
movies_server() is the adapter between MovieData and Shiny. It creates the R6 instance once, registers cleanup, and wraps filter() in a reactive so that 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":"16px"}}}%%
flowchart TD
UIIn["UI Inputs<br>(sliders, selects,<br>text boxes)"]
FilServ["mod_filters_server()<br>returns reactive list"]
FilReact["filters()<br>{reviews, oscars, year,<br>boxoffice, genre,<br>director, cast,<br>xvar, yvar}"]
MovReact["movies()<br>reactive"]
MovData["movie_data$filter()<br>R6 method"]
PltServ["mod_plot_server()"]
Scatter["renderPlotly()<br>output$scatter"]
Count["renderText()<br>output$n_movies"]
UIIn --> FilServ
FilServ --> FilReact
FilReact --> MovReact
MovData --> MovReact
MovReact --> PltServ
FilReact --> PltServ
PltServ --> Scatter
PltServ --> Count
34.6 Module Composition
34.6.1 mod_filters: returning a reactive list
mod_filters_server() does one thing: collect all filter inputs and return them as a single reactive list. Bundling the outputs into one reactive means downstream consumers 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. This is simpler than returning areactiveValues()object and avoids partial-update race conditions.
34.6.2 mod_plot: consuming movies and filters
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; you can pass any reactive data frame in tests.
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.
34.6.3 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.7 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.8 Testing MovieData Without Shiny
Because MovieData has no reactive internals, you test it with plain testthat β no shinytest2, no testServer(), no running app required.
# 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.
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.
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 in
R/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.
The pattern scales naturally: add more R6 classes for additional data sources or domain logic, keep each one free of Shiny dependencies, and wire them into the reactive graph through thin adapter reactives in the server.
This app comes from the shiny-examples GitHub repo.β©οΈ