34  R6

Published

2026-08-07

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:

34.1 What Is {R6}?

R has several object-oriented systems, and I’ve found that choosing the right one matters for Shiny architecture.

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

  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.

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.

Mutable state: State that changes during the lifetime of an object or session. Some objects exist in memory and are modified in place. Without mutable state, we’d have to create a new object every time we needed to change anything, which is wasteful and impractical for long-lived resources like database connections.

Reference semantics: When all variables pointing to the same object see the same underlying data, modifications are immediately visible everywhere. In contrast, copy-on-modify semantics (S3, S4) create a new copy whenever we modify an object, leaving the original untouched. Reference semantics are essential for managing mutable state efficiently: we store the object once and every part of our code that needs it sees the same, up-to-date version.

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":"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
  

movexplR6 architecture: R6 owns state, Shiny owns reactivity

{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 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. {R6}’s reference semantics mean this single all_movies data 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

MovieData R6 class: public interface vs. private internals

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.

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.

The logger package provides structured logging that works seamlessly in {R6} classes. Log informational events (log_info) so we can trace execution, log errors (log_error) to capture what went wrong, and log debug messages (log_debug) for detailed inspection during development.

Unlike print() statements, logs can be written to files, rotated automatically, and filtered by level in production without changing code.

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.

Each Shiny user session should have its own {R6} instance. If we create an {R6} object at the package level (outside server()), all sessions share the same instance. This means User A’s database query could pollute User B’s data cache, or worse. Always instantiate {R6} objects inside server() or a module server function.

Don’t worry–shiny will automatically clean them up when the session ends. This is how reference semantics become an asset instead of a liability.

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() 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":"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
  

Reactive data flow: Shiny reactivity wraps R6 state

The movies reactive is the adapter between {R6} and Shiny, embodying the clean line between ‘things that change at runtime’ and ‘things that handle reactivity.’

  • Above the adapter: plain {R6} methods that return data frames and know nothing about Shiny.

  • Below the adapter: Shiny renders and observers that know nothing about the database.

This boundary makes both sides independently testable and lets we evolve them separately.

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 a reactiveValues() 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 .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.

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.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()

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

Documenting {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() guarantees disconnect() 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 = 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.

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)
      )
    }
  )
})

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.

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.


  1. This app comes from the shiny-examples GitHub repo.↩︎