34  πŸ— R6

Published

2026-07-07

ImportantWARNING

This chapter is being developed. Thank you for your patience.

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:

34.1 What Is R6?

R has several object-oriented systems.

  1. 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.

  2. 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.

install.packages("R6")
library(R6)

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

movexplR6 at a glance

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 by inherits() checks and in error messages.
2
initialize() runs when you call MovieData$new(db_path). It opens the connection and immediately loads data; any error during load_data() is logged and re-thrown so the caller knows construction failed.
3
filter() operates on all_movies in memory; no additional database queries happen after initialization. boxoffice is in millions in the UI but raw dollars in the data, so the method scales before filtering.
4
disconnect() guards against calling DBI::dbDisconnect() on an already-closed connection β€” important because finalize() and shiny::onStop() may both attempt cleanup.
5
finalize() is R6’s garbage-collection hook; it is called when the object is garbage-collected. In practice onStop() disconnects first, but finalize() is a safety net.
6
load_data() is private; callers cannot invoke it directly. It joins two SQLite tables with dbplyr, 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()
  }
  

movexplR6 at a glance

34.3.1 initialize(): connecting and loading

NotePublic vs. private

Put everything callers need in public. Put implementation details in private. Private members are inaccessible outside the class (attempting md$load_data() throws an error), which prevents callers from accidentally corrupting internal state.

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.R

Add 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() runs initialize(): opens the connection and loads all movies into all_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 private finalize() 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 movies reactive calls movie_data$filter() with the current filter values whenever filters() invalidates. Notice that movie_data is captured by reference β€” R6’s reference semantics mean no copying happens here.
6
Both reactives (movies and filters) are passed into the plot module so it can display the filtered count and render axes from filters()$xvar and filters()$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

Reactive data flow

NoteThe adapter pattern

The movies reactive is the adapter between R6 and Shiny. Above the adapter: plain R6 methods that return data frames and know nothing about reactivity. Below the adapter: Shiny renders and observers that know nothing about the database. This boundary makes both sides independently testable.

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 a reactiveValues() 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 .data pronoun to select columns by name at runtime; f$xvar and f$yvar come from the filters() 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-run movie_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 to mod_filters_server() in movies_server().
2
mod_plot_ui("plot") likewise pairs with mod_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()

Session Lifecycle

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() guarantees disconnect() 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 = 1 every returned row must have won at least one Oscar, so has_oscar should be "Yes" for all.
2
Calling disconnect() twice must not throw; the internal DBI::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)
      )
    }
  )
})
TipTesting at two levels

Test the R6 class with plain testthat (no Shiny needed). Test the modules with shiny::testServer() (no browser needed). Reserve shinytest2 for end-to-end flows that require a real browser.

34.9 Recap

movexplR6 demonstrates a clean division of labor between R6 and Shiny:

  • MovieData owns the database connection and all filtering logic. It is framework-agnostic and fully testable with plain testthat. Keep R6 classes like this: one responsibility, no reactive internals.
  • movies_server() is the adapter. It creates MovieData once per session, wraps filter() in a reactive() so the rest of the app can depend on it, and registers cleanup with shiny::onStop().
  • Modules (mod_filters, mod_plot) communicate through reactive arguments passed by the server. Neither module knows about MovieData directly; 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 inside server() or module server functions.
  • finalize() is a backstop, not the primary cleanup. Register disconnect() with shiny::onStop(); let finalize() 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.


  1. This app comes from the shiny-examples GitHub repo.β†©οΈŽ