Shiny, R packages, and R6

Managing state and business logic with R6 classes in Shiny app-packages

R6
Shiny
Author

Martin Frigaard

Published

July 5, 2026

As your Shiny application grows, organizing code into modular, object-oriented pieces becomes crucial. While Shiny’s reactive programming model is naturally functional, R6 classes provide a powerful abstraction for encapsulating state, behavior, and business logic. If you’re building a Shiny app as an R package, R6 brings real benefits: clean separation of concerns, easier testing, and reusable module patterns that survive across projects.

In this post, we’ll explore:

  1. What R6 is and why it shines in Shiny app-packages
  2. Defining R6 classes for Shiny modules (UI and server)
  3. Wiring R6 objects into your app’s reactive flow
  4. A real working example from a stock volatility dashboard

What is R6?

R6 is an object-oriented system in R that creates reference-based objects. Unlike S3 or S4, R6 objects are mutable, support encapsulation (public and private members), and feel familiar to developers from Python or Java.

R6 is especially useful for:

  1. Bundling UI generation with business logic (module as a unified object)
  2. Managing state tied to a user session or module instance
  3. Encapsulating dependencies and configuration

In an app-package, R6 prevents you from scattering related logic across separate files and avoids the need for global state or overly nested reactive functions.

Defining an R6 Shiny Module

An R6 module wraps both its UI and server logic into one class. Here’s a simplified example from the Rsixer stock dashboard app:

ModInputs <- R6::R6Class(
  "ModInputs",

  public = list(
    initialize = function(id = "inputs") {
      private$id <- id
      private$ns <- shiny::NS(id)
    },

    ui = function() {
      bslib::sidebar(
        shiny::selectizeInput(
          inputId = private$ns("tickers"),
          label = "Tickers",
          multiple = TRUE
        ),
        shiny::dateRangeInput(
          inputId = private$ns("dates"),
          label = "Date range"
        )
      )
    },

    server = function() {
      shiny::moduleServer(private$id, function(input, output, session) {
        shiny::reactive({
          list(
            tickers = input$tickers,
            from = input$dates[1],
            to = input$dates[2]
          )
        })
      })
    }
  ),

  private = list(
    id = NULL,
    ns = NULL
  )
)
1
initialize() stores the module’s namespace ID and creates a namespace function
2
ui() is a public method that builds UI tags; it uses private$ns() to namespace inputs
3
server() wraps the module server logic and returns a reactive expression
4
private members hold internal state; can’t be accessed from outside the object

Using R6 Modules in Your App

In your app’s UI, instantiate and call the ui() method:

app_ui <- function() {
  page_fillable(
    inputs <- ModInputs$new(id = "inputs")
    inputs$ui()
  )
}

In your app’s server, instantiate and call the server() method, then wire its output to downstream modules:

app_server <- function(input, output, session) {
  # ── Input module ──────────────────────────
  inputs <- ModInputs$new(id = "inputs")
  inputs_r <- inputs$server()  # Returns reactive list

  # ── Output module ─────────────────────────
  outputs <- ModOutputs$new(id = "outputs")
  perf_r <- outputs$server(inputs_r = inputs_r)  # Consumes input_r

  # ── Download module ──────────────────────
  download <- ModDownload$new(id = "download")
  download$server(inputs_r = inputs_r, perf_r = perf_r)
}

Each module is instantiated fresh per session, so there’s no global state. Reactive dependencies flow naturally between them.

Reactivity and R6

R6 objects themselves are not reactive; they’re just containers for logic. To make your app respond to changes in an R6 module’s state, you must return reactive expressions from the server() method.

In ModInputs, the server() method returns a reactive list:

server = function() {
  shiny::moduleServer(private$id, function(input, output, session) {
    # Return a reactive list so downstream modules can access tickers, dates, etc.
    shiny::reactive({
      list(
        tickers = input$tickers,
        from = input$dates[1],
        to = input$dates[2]
      )
    })
  })
}

Then in ModOutputs, you consume that reactive list:

server = function(inputs_r) {
  shiny::moduleServer(private$id, function(input, output, session) {
    # Downstream modules receive inputs_r and call it as a function
    prices_r <- shiny::eventReactive(inputs_r()$fetch, {
      inp <- inputs_r()
      get_stock_prices(
        tickers = inp$tickers,
        from = inp$from,
        to = inp$to
      )
    })
  })
}

This pattern keeps the dependency graph explicit and testable.

Organizing R6 Classes in a Package

Structure your R6 classes for clarity and maintainability:

  1. Place each class definition in R/mod_*.R (one file per module)
  2. Use @export roxygen tags to make classes available to package users
  3. Write tests with testthat that instantiate and call the class methods
  4. Never instantiate R6 objects at package load time; only inside app_server() or module server functions
  5. Keep private members truly private; expose only what downstream modules need

Example from Rsixer’s package structure:

R/
  mod_inputs.R      # ModInputs class
  mod_outputs.R     # ModOutputs class
  mod_download.R    # ModDownload class
  app_server.R      # Wires them together
  app_ui.R          # Calls ui() methods

Recap

R6 classes provide a clean, testable way to organize Shiny app-packages. By bundling UI generation and server logic into a single object, you gain encapsulation, reduce global state, and make dependencies explicit. It’s a pattern that scales; the Rsixer app uses R6 modules for inputs, outputs, and downloads, each passing reactive values to the next in a clear data flow.

While not every Shiny app needs R6, it’s worth learning if you’re building production packages or multi-developer projects. It turns your modules into reusable components that you (or your team) can confidently import into future apps.