Shiny, R6, and R packages

Managing app state with R6 classes in Shiny app-packages

Shiny
Package Development
Author

Martin Frigaard

Published

July 25, 2026

As Shiny applications grow, 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-package, R6 brings real benefits (clean separation of concerns, easier testing, reusable module patterns, etc.) 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 the app’s reactive flow

What is R6?

R6 is an object-oriented system in R that creates reference-based objects. Unlike S3 or S4, R6 objects are mutable and support encapsulation (i.e., public and private members). R6 objects feel familiar for Python or Java developers because they both include similar implementations.

To create R6 objects, install the R6 package:

install.packages("R6")

R6 objects are used throughout the R ecosystem:

  • plumber2 uses R6 to organize REST API routers and middleware; each route handler is a method bound to an R6 object, making routing logic reusable and testable

  • shinytest2 uses R6 for app testing objects; the AppDriver class bundles together UI selectors, server interactions, and assertions, making test code fluent and maintainable

Even Shiny itself uses reactive objects (which are reference-based) to manage app state (so using R6 objects in an app-package is a natural extension of that pattern).

In Shiny app-packages, 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

R6 vs standard shiny modules

I recently built tooltipexplorer, a stock volatility app to demonstrate various hover-info and tooltips. I’ve converted it into rsixer, an R6 version of the same app to flesh out some of the differences between standard Shiny app-packages and R6 Shiny app-packages. I’ve displayed the .R source files in three categories below: application structure (modules, UI/server, launch), data/computation utilities, and helpers.

Standard

Access the code here: mjfrigaard.github.io/tooltipexplorer

App structure

I’ve included the Shiny app functions from the R/ folder in the tree below to help understand how tooltipexplorer is built.

R/
├── app_server.R
├── app_ui.R
├── launch.R
├── mod_download.R
├── mod_inputs.R
├── mod_outputs.R
├── mod_hoverinfo.R
└── mod_tooltip.R
1
Wires together function-based modules by calling mod_inputs_server("inputs"), mod_outputs_server("outputs", ...), mod_download_server("download", ...) sequentially; passes reactive values between them
2
Top-level UI function; calls mod_inputs_ui(), mod_outputs_ui(), and mod_download_ui() to compose the page
3
App entry point; calls shinyApp(ui, server)
4
Function-based download module; contains both mod_download_ui(id) and mod_download_server(id) functions in one file
5
Function-based inputs module; contains mod_inputs_ui(id) and mod_inputs_server(id) returning a reactive list of user selections
6
Function-based outputs module; consumes inputs_r and renders performance metrics and charts
7
Helper that wraps hover/tooltip text for reactable table cells
8
Tooltip icon helper component used in module labels

Utilities

The utilities cover logging, collecting data, and computations.

R/
├── app_set_log_threshold.R
├── with_logging.R
├── compute_rolling_vol.R
├── get_ff3_factors.R
├── get_stock_prices.R
├── get_stock_returns.R
└── summarise_performance.R
1
Logging configuration setup; initializes logger threshold for the app
2
Logging wrapper utility that tracks side-effects and manages logging context
3
Computes rolling volatility metric for stock analysis
4
Fetches Fama-French 3-factor data from external source
5
Fetches historical stock price data from API
6
Calculates stock returns from price data
7
Calculates performance summary metrics (returns, Sharpe ratio, etc.)

Helpers

These are theme/styling functions, operators, and ticker symbols.

R/
├── custom_head.R
├── default_tickers.R
├── setup_theme.R
├── utils_operators.R
└── utils_reactable_theme.R
1
Helper function for displaying data in a compact, readable format
2
Exports default ticker symbols as a package constant
3
Theme and styling configuration for the app
4
Custom operators (%>%, %||%, etc.)
5
reactable table theme utilities for consistent table styling

R6

Access the code here: mjfrigaard.github.io/rsixer

The file names are pretty similar; there are 19 files vs. 20 in tooltipexplorer. Using R6 objects means bundling without the need for separate UI/server function pairs.

If you’re wondering why the applications look so similar, it’s because the rsixer application is a direct conversion of the tooltipexplorer into the R6 architecture.

App structure

R/
├── app_server.R
├── app_ui.R
├── launch.R
├── ModDownload.R
├── ModInputs.R
├── ModOutputs.R
├── mod_hoverinfo.R
└── mod_tooltip.R
1
Instantiates R6 module classes (ModInputs$new("inputs"), ModOutputs$new("outputs"), ModDownload$new("download")) and calls their $server() methods sequentially; passes reactive values downstream
2
Top-level UI function; calls $ui() method on each R6 instance to compose the page
3
App entry point; calls shinyApp(ui, server)
4
R6 class bundling download UI and server logic; single instantiation ensures UI and server share the same id internally (equivalent to mod_download.R in tooltipexplorer)
5
R6 class bundling inputs UI and server logic; returns reactive list from $server() method (equivalent to mod_inputs.R in tooltipexplorer)
6
R6 class bundling outputs UI and server logic; consumes reactive inputs from ModInputs (equivalent to mod_outputs.R in tooltipexplorer)
7
Helper that wraps hover/tooltip text for reactable table cells (identical to tooltipexplorer)
8
Tooltip icon helper component used in module labels (identical to tooltipexplorer)

Utilities

rsixer omits get_ff3_factors.R, but the other utilities match those in tooltipexplorer.

R/
├── app_set_log_threshold.R
├── with_logging.R
├── compute_rolling_vol.R
├── get_stock_prices.R
├── get_stock_returns.R
└── summarise_performance.R
1
Logging configuration setup; initializes logger threshold for the app (identical to tooltipexplorer)
2
Logging wrapper utility that tracks side-effects and manages logging context (identical to tooltipexplorer)
3
Computes rolling volatility metric for stock analysis (identical to tooltipexplorer)
4
Fetches historical stock price data from API (identical to tooltipexplorer)
5
Calculates stock returns from price data (identical to tooltipexplorer)
6
Calculates performance summary metrics (returns, Sharpe ratio, etc.) (identical to tooltipexplorer)

Helpers

Theme/styling functions, operators, and ticker symbols.

R/
├── default_tickers.R
├── setup_theme.R
├── utils_operators.R
└── utils_reactable_theme.R
1
Exports default ticker symbols as a package constant (identical to tooltipexplorer)
2
Theme and styling configuration for the app (identical to tooltipexplorer)
3
Custom operators (%>%, %||%, etc.) (identical to tooltipexplorer)
4
reactable table theme utilities for consistent table styling (identical to tooltipexplorer)

Architecture

Both tooltipexplorer’s function-based and rsixers R6 module patterns work, the difference is in how they organize the code and protect against state leaks as the app grows.

Standard modules

In tooltipexplorer, the modules are split into two independent functions living in the same file (i.e., mod_inputs.R contains both mod_inputs_ui(id) and mod_inputs_server(id)). The UI function returns HTML tags and the server function returns a reactive list.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    subgraph FuncBased["<strong>Function-Based</strong>"]
        direction LR
        UICall("mod_inputs_ui('inputs')")
        ServerCall("mod_inputs_server('inputs')")
        Separate(("<em>Two separate<br/>function calls</em>"))
    end

    style UICall fill:#FFF8E7,color:#000000,stroke:#999,font-family:monospace
    style ServerCall fill:#FFF8E7,color:#000000,stroke:#999,font-family:monospace
    style Separate fill:#FFE0E0,color:#000000,stroke:#999

Both UI and server function must receive the same id argument to share a namespace. The responsibility for keeping UI and server in sync falls entirely on the developer. Both functions have an id parameter, so nothing in the architecture ties them together except the discipline of passing the same value to each.

R6 modules

When writing regular Shiny modules, both the UI and server function are included in a single file. R6 modules wrap both the UI and server logic into a single class. Below is a simplified example from the rsixer (view the full version on GitHub.).

hide/show ModInputs
ModInputs <- R6::R6Class(
  "ModInputs",
  public = list(
    initialize = function(id = "inputs") {
      private$id <- id
      private$ns <- shiny::NS(id)
    },
    ui = function() {
      bslib::sidebar(
        width = 280,
        bg = "#f8f9fa",
        shiny::selectizeInput(
          inputId = private$ns("tickers"),
          label = shiny::tags$span(
            "Tickers",
            mod_tooltip(
              trigger = bsicons::bs_icon("info-circle"),
              type = "bslib",
              contents = "Enter one or more stock ticker symbols (e.g. AAPL, MSFT).",
              size = "0.85rem",
              style = "color:#6c757d"
            )
          ),
          choices = default_tickers,
          selected = c("AAPL", "MSFT", "GOOGL"),
          multiple = TRUE,
          options = list(
            plugins = list("remove_button"),
            placeholder = "Add a ticker\u2026",
            create = TRUE
          )
        ),
        shiny::dateRangeInput(
          inputId = private$ns("dates"),
          label = "Date range",
          start = Sys.Date() - 365,
          end = Sys.Date(),
          min = "2000-01-01",
          max = Sys.Date()
        ),
        shiny::sliderInput(
          inputId = private$ns("vol_window"),
          label = shiny::tags$span(
            "Rolling vol window (days)",
            mod_tooltip(
              trigger = bsicons::bs_icon("info-circle"),
              type = "bslib",
              contents = "Number of trading days used for the rolling volatility calculation.",
              size = "0.85rem",
              style = "color:#6c757d"
            )
          ),
          min = 5L,
          max = 120L,
          value = 30L,
          step = 5L
        ),
        shiny::actionButton(
          inputId = private$ns("fetch"),
          label = "Fetch data",
          icon = shiny::icon("download"),
          class = "btn-primary w-100"
        ),
        bslib::card(
          bslib::card_header(
            bsicons::bs_icon("file-earmark-arrow-down"), " Download Report"
          ),
          bslib::card_body(
            shiny::selectInput(
              inputId = private$ns("format"),
              label = "Report format",
              choices = c("HTML" = "html", "PDF" = "pdf"),
              selected = "html"
            ),
            shiny::downloadButton(
              outputId = private$ns("download"),
              label = "Download",
              icon = shiny::icon("download"),
              class = "btn-outline-primary w-100"
            )
          )
        )
      )
    },
    
    server = function() {
      shiny::moduleServer(private$id, function(input, output, session) {
        shiny::observe({
          shiny::req(input$fetch)
          if (length(input$tickers) == 0) {
            shiny::showNotification(
              "Please select at least one ticker.",
              type = "warning"
            )
          }
        })
        shiny::reactive({
          with_logging(
            context = "ModInputs / reactive list",
            ns = "rsixer/inputs",
            {
              inp <- list(
                tickers = input$tickers,
                from = input$dates[1],
                to = input$dates[2],
                vol_window = input$vol_window,
                fetch = input$fetch,
                format = input$format
              )
              inp
            }
          )
        })
      })
    }
  ),
  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
Stock ticker picker
4
Date range input
5
Rolling-vol window
6
Fetch stock data
7
Report download (placeholder)
8
server() wraps the module server logic and returns a reactive expression
9
Fetch button observer
10
Reactive inputs list
11
private members hold internal state; can’t be accessed from outside the object

rsixer bundles both UI and server into a single R6 class. ModInputs$new("inputs") creates one instance where the id is stored in private$id and automatically available to both the ui() and server() methods.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%


flowchart TD
    subgraph R6Based["<strong>R6 Class</strong>"]
        New["<strong>ModInputs$new('inputs')</strong>"]
        Instance["Single instance<br/>owns <code>id</code> & <code>ns</code>"]
        UiM["<strong>$ui()</strong>"]
        ServM["<strong>$server()</strong>"]
    end

    New -->|"binds <code>id</code>"| Instance
    Instance -->|"uses <code>id</code>"| UiM
    Instance -->|"uses <code>id</code>"| ServM

    style New fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style Instance fill:#E0F0ED,color:#000000,stroke:#4CBB9D
    style UiM fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style ServM fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace

The id is bound at instantiation and encapsulated in the object. Both methods access it from the same source, so namespace mismatches are architecturally impossible. I’ll dive into the differences between public and private methods below.

Public methods

Public methods are functions defined in the public list that can be called from outside the R6 object. In Shiny modules, public methods expose the behavior we want users to interact with (like building UI or running server-side logic). They have access to private members via private$, but callers cannot access or modify private state directly.

initialize:

initialize() does just what it sounds like–it initializes the module namespace by storing the ID (private$id), creating a namespace function (private$ns) and adding element ID prefixes. This runs once when we instantiate the object with $new().

initialize = function(id = "inputs") {
  private$id <- id
  private$ns <- NS(id)
}

ui:

ui() constructs the module’s UI components by wrapping HTML tags with the private namespace function. It also returns HTML tags that get inserted into the app’s main UI. All the input IDs are namespaced via private$ns() to prevent conflicts.

ui = function() {
  sidebar(
    selectizeInput(
      inputId = private$ns("tickers"),
      label = tags$span("Tickers"),
      # ...
    ),
    dateRangeInput(
      inputId = private$ns("dates"),
      label = "Date range",
      # ...
    ),
    sliderInput(
      inputId = private$ns("vol_window"),
      label = shiny::tags$span("Rolling vol window (days)"),
      # ...
    ),
    actionButton(
      inputId = private$ns("fetch"),
      label = "Fetch data",
      # ...
    ),
    selectInput(
      inputId = private$ns("format"),
      label = "Report format",
      # ...
    )
  )
}
1
Returns: HTML sidebar with namespaced input IDs

server:

The server() encapsulates reactive logic within the module’s namespace via moduleServer(), accesses reactives created inside this method, then returns them for downstream modules to consume. The returned reactive value maintains isolation from parent and sibling modules.

server = function() {
  moduleServer(id = private$id, module = function(input, output, session) {
    reactive({
      inp <- list(
        tickers = input$tickers,
        from = input$dates[1],
        to = input$dates[2],
        vol_window = input$vol_window,
        fetch = input$fetch,
        format = input$format
      )
      inp
    })
  })
}
1
Returns: reactive list that downstream modules can access

The lifecycle flows like this: initialize() runs at instantiation; then ui() is called in app_ui() and server() is called in app_server(), but both methods share access to private$id and private$ns.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart LR
    subgraph "<strong>R6 Module Lifecycle</strong>"
        Init["<strong>initialize(id)</strong><br><em>Runs at instantiation</em>"]
        Id("<strong>private$id</strong>")
        Ns("<strong>private$ns()</strong>")
        UI["<strong>ui()</strong><br/><em>Called in<br>app_ui()</em>"]
        Server["<strong>server()</strong><br><em>Called in app_server()</em>"]
    end

    Init -->|"stores"| Id
    Init -->|"creates"| Ns
    UI -->|"uses"| Ns
    Server -->|"uses"| Ns

    style Init fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style Id fill:#FFFFFF,color:#000000,stroke:#333,font-family:monospace
    style Ns fill:#FFFFFF,color:#000000,stroke:#333,font-family:monospace
    style UI fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style Server fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace

Private methods

The private method starts as list with NULL values for id and ns. These store the module’s identifier and namespace function, which is the internal application state that is accessed by public methods to generate properly scoped references.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    subgraph "<strong>Namespace Isolation</strong>"
        Ns(["<strong>ns()</strong>"])
        IDs("input/output IDs")
        Prefix("Prefixed IDs<br><code>ns-input</code>")
    end

    Ns -->|prefixes| IDs
    IDs -->|creates| Prefix
    Prefix -->|prevents| Conflicts(["<em>ID conflicts<br>across modules</em>"])

    style Ns fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace,text-align:'center'
    style IDs fill:#FFFFFF,color:#000000,stroke:#333
    style Prefix fill:#FFFFFF,color:#000000,stroke:#333
    style Conflicts fill:#FFE0E0,color:#000000,stroke:#c0392b
    

Application state

Application state matters as complexity grows. In tooltipexplorer, the state is scattered across environments. The inputs module manages its own reactive list inside the server() closure, the outputs module has its own logic, and if we need shared state between modules (like a cached computation or a user preference), we either pass it through function arguments or store it in a variable at the app level.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    subgraph FuncFlow["<strong>Function-Based Flow<strong>"]
        FUI("<strong>mod_inputs_ui()</strong>")
        FS("<strong>mod_inputs_server()</strong>")
        FReact[/"reactive list"/]
        FPass("<strong>mod_outputs_server()</strong>")
        FWhere(["<em>State lives where?<br/>Inside closure or global?</em>"])
    end
    
    FUI --"collects inputs"--> FS --"returns"--> FReact --"pass to"--> FPass
    FPass -.-> FWhere
    
    style FWhere fill:#FFE0E0,color:#000000,stroke:#c0392b
    style FUI font-family:monospace
    style FS font-family:monospace
    style FPass font-family:monospace
    

As our dashboard grows (more modules, more dependencies) tracking where the state lives becomes harder. Did that cached value come from the inputs module or somewhere else? Is it being modified by multiple modules? The architecture doesn’t prevent us from creating a tangled mess (it just assumes we’ll avoid them).

R6 modules make state ownership explicit. Each instance is responsible for its own private members. When ModOutputs needs data from ModInputs, it receives the reactive list returned by ModInputs$server() and consumes it as a parameter.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    subgraph R6Flow["<strong>R6 Class Flow</strong>"]
        MI["<strong>ModInputs$new()</strong>"]
        MS["<strong>ModInputs$server()</strong>"]
        MReact[/"reactive list"/]
        MPass["<strong>ModOutputs</strong>"]
        MOwn(["<em>State clearly owned<br/>by instance</em>"])
    end

    MI --"collects inputs"--> MS --"returns"--> MReact --"pass to"--> MPass
    MPass --> MOwn

    style MI font-family:monospace
    style MS font-family:monospace
    style MPass font-family:monospace
    style MOwn fill:#E0F0ED,color:#000000,stroke:#4CBB9D
    

R6 dependencies flow in one direction so the downstream modules receive what they need, and they can’t reach back and modify the upstream module’s private state. The R6 architecture itself prevents certain classes of bugs.

Encapsulation in practice

To see how encapsulation plays out at runtime, I’ve added some debugging output to both apps that inspects the reactive state. I’ve found these methods are helpful when I want to render the app state during development.

Function-based inspection (tooltipexplorer)

In tooltipexplorer, we add simple inspection of input values and the return value of the inputs module:

# in R/app_ui.R
tags$div(
  tags$b(code("reactiveValuesToList")),
  verbatimTextOutput(outputId = "vals"),
  tags$b(code("inputs_r()")),
  verbatimTextOutput(outputId = "dev")
)

# in R/app_server.R
output$vals <- renderPrint({
  vals <- reactiveValuesToList(x = input, all.names = TRUE)
  str(vals)
})
output$dev <- renderPrint({
  req(inputs_r)
  str(inputs_r())
})

At runtime, we see input contains namespaced IDs like inputs-tickers, inputs-dates, etc. The inputs_r() function returns a clean list of reactive values extracted from the module:

reactiveValuesToList (raw Shiny input values)
List of 6
 $ outputs-tabs     : chr "bslib"
 $ inputs-fetch     : 'shinyActionButtonValue' int 0
 $ inputs-tickers   : NULL
 $ download-format  : chr "html"
 $ inputs-dates     : Date[1:2], format: "2025-07-31" "2026-07-31"
 $ inputs-vol_window: int 30
inputs_r() (returned from mod_inputs_server)
List of 5
 $ tickers   : NULL
 $ from      : Date[1:1], format: "2025-07-31"
 $ to        : Date[1:1], format: "2026-07-31"
 $ vol_window: int 30
 $ fetch     : 'shinyActionButtonValue' int 0

The module function returns a reactive list that downstream code depends on. But there’s no object to inspect. If we need to debug, we’re looking at raw input values (or the return values). The namespace (inputs) is implicit in function names and parameter passing.

R6 object inspection (rsixer)

In rsixer, we can inspect both the R6 object and its return value:

# in R/app_ui.R
tags$div(
  tags$b(code("reactiveValuesToList")),
  verbatimTextOutput(outputId = "vals"),
  tags$b(code("inputs (R6 object)")),
  verbatimTextOutput(outputId = "dev_inputs"),
  tags$b(code("inputs_r()")),
  verbatimTextOutput(outputId = "dev_inputs_r")
)

# in R/app_server.R
output$vals <- renderPrint({
  vals <- reactiveValuesToList(x = input, all.names = TRUE)
  str(vals)
})
output$dev_inputs <- renderPrint({
  req(inputs)
  str(inputs)
})
output$dev_inputs_r <- renderPrint({
  req(inputs_r)
  str(inputs_r())
})

The reactiveValuesToList output is nearly identical to the function-based version (Shiny’s namespacing layer works the same):

reactiveValuesToList (raw Shiny input values)
List of 6
 $ outputs-tabs     : chr "bslib"
 $ inputs-fetch     : 'shinyActionButtonValue' int 0
 $ inputs-tickers   : NULL
 $ inputs-format    : chr "html"
 $ inputs-dates     : Date[1:2], format: "2025-07-31" "2026-07-31"
 $ inputs-vol_window: int 30

But now we can inspect the inputs object itself (i.e., the actual R6 instance):

inputs (R6 ModInputs object)
Classes 'ModInputs', 'R6' <ModInputs>
  Public:
    clone: function (deep = FALSE)
    initialize: function (id = "inputs")
    server: function ()
    ui: function ()
  Private:
    id: inputs
    ns: function (id)

This output tells us immediately that the module owns:

  1. an id (bound at instantiation)
  2. a namespace function (ns)
  3. has public methods for ui() and server()

Everything the module needs is encapsulated in one object (inputs). If we wanted to use a different namespace, we’d instantiate ModInputs$new(id = "different"), and both ui() and server() would automatically use that new ID.

Finally, the inputs_r() output shows what the server method returns (6 fields, including format):

inputs_r() (returned from inputs$server())
List of 6
 $ tickers   : NULL
 $ from      : Date[1:1], format: "2025-07-31"
 $ to        : Date[1:1], format: "2026-07-31"
 $ vol_window: int 30
 $ fetch     : 'shinyActionButtonValue' int 0
 $ format    : chr "html"

Why encapsulation matters

The R6 approach makes the module’s identity and dependencies explicit. When we see inputs$server(), we know:

  1. inputs is a self-contained object with its own namespace
  2. The ID is bound to that object, so we can’t accidentally call ui() with one ID and server() with another
  3. All state (private$id, private$ns) is managed internally

With function-based modules (like those in tooltipexplorer), we have to remember to pass the same id to both mod_inputs_ui() and mod_inputs_server() every time. As we saw in Section 4, a single typo ("input" vs. "inputs") causes a silent namespace mismatch. The UI and server lose sync, but Shiny raises no error.

Here’s a concrete example. Imagine we’re building tooltipexplorer’s UI and accidentally pass the wrong ID to the server:

hide/show using function-based module approach
app_ui <- function() {
  page_sidebar(
    sidebar = mod_inputs_ui("inputs"),
    ...
  )
}

app_server <- function(input, output, session) { 
  inputs_r <- mod_inputs_server("input")
  outputs_r <- mod_outputs_server("outputs",
                                  inputs_r = inputs_r)
}
1
Correct ID
2
Oops! wrong ID passed here
3
No errors (app works, but no inputs are passed)

The app will render and run without errors. The sidebar will work. But inputs_r will be empty because the UI and server are in different namespaces. The bug is silent and can take hours to track down.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    
    subgraph FuncProblems["<strong>Function-Based Risk</strong>"]
        UI1["<strong>mod_inputs_ui('inputs')</strong>"]
        ServeWrong["<strong>mod_inputs_server('input')</strong>"]
        BugSilent(["<em>Silent namespace mismatch (no error, app breaks)</em>"])
    end

    UI1 -->|"wrong ID"| ServeWrong
    ServeWrong --> BugSilent

    style BugSilent fill:#FFE0E0,color:#000000,stroke:#c0392b
    style UI1 font-family:monospace
    style ServeWrong font-family:monospace
    

With R6, this class of bug is impossible:

hide/show using R6 module approach
app_server <- function(input, output, session) {
  inputs <- ModInputs$new(id = "inputs")
  inputs_r <- inputs$server()
}
1
Single instantiation, ID is bound to the object
2
Uses same ID internally

The instance owns both UI and server; they’re locked together at creation time.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD

    subgraph R6Safe["<strong>R6 Protection</strong>"]
        Instance["<strong>ModInputs$new('inputs')</strong>"]
        Ui2["<strong>$ui()</strong>"]
        Serve2["<strong>$server()</strong>"]
        BugFree(["<em>Same ID throughout (impossible to mismatch)</em>"])
    end
    
    Instance -->|"one ID"| Ui2
    Instance -->|"same ID"| Serve2
    Serve2 --> BugFree
    
    style BugFree fill:#E0F0ED,color:#000000,stroke:#4CBB9D
    style Instance fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style Ui2 fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style Serve2 fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    

Trade-offs

The trade-off is complexity. Function-based modules are lighter weight. If our module is genuinely simple (a filter input, a summary statistic) wrapping it in an R6 class adds indirection without clear benefit.

In rsixer’s, the R6 classes make sense because each module manages nontrivial state. ModInputs handles date ranges, ticker selection, and rolling-window parameters while ModOutputs computes performance metrics and manages reactivity across those inputs. The added structure pays for itself.

In a simpler app, we might never hit the ownership or namespace problems that R6 prevents, and the function-based approach keeps the code readable.

When to use each:

  • Use function-based modules for simple, independent modules (filters, info boxes) with minimal shared state.

  • Use R6 for complex modules that manage multiple inputs, outputs, or session-level state; when multiple developers work on the same app; or when you want to reuse the same module class across projects

Using R6 modules

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

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

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

app_server <- function(input, output, session) {
  
  inputs <- ModInputs$new(id = "inputs")
  inputs_r <- inputs$server()

  outputs <- ModOutputs$new(id = "outputs")
  perf_r <- outputs$server(inputs_r = inputs_r)

  download <- ModDownload$new(id = "download")
  download$server(inputs_r = inputs_r, perf_r = perf_r)
}
1
Input module (returns reactive list)
2
Output module (consumes input_r)
3
Download module (consumes inputs_r and perf_r)

Each module is instantiated fresh per session, so there’s no global state (and the reactive dependencies flow naturally between them).

App state patterns

Function-based modules (like those in the tooltipexplorer app) split UI and server into separate functions that both take an id argument:

# function-based approach (tooltipexplorer style)
mod_inputs_ui <- function(id) {
  ns <- NS(id)
}

mod_inputs_server <- function(id) {
  moduleServer(id, function(input, output, session) {
    reactive({ list(...) })
  })
}

inputs_r <- mod_inputs_server("inputs")
outputs_r <- mod_outputs_server("outputs", inputs_r = inputs_r)
1
build UI using ns() for namespacing
2
return reactive expression
3
In app_server, inputs are consumed and outputs are returned

R6 modules bundle both UI and server into a class with public methods:

# R6 approach (rsixer style)
ModInputs <- R6::R6Class(
  "ModInputs",
  public = list(
    ui = function() { ... },
    server = function() { ... }
  ),
  private = list(id = NULL, ns = NULL)
)

inputs <- ModInputs$new(id = "inputs")
inputs_r <- inputs$server()
1
Name module/class
2
Public methods
3
Build UI
4
Server logic
5
Private methods (empty list)
6
Implementation (creating inputs and inputs_r)

Here’s how the two patterns compare when wiring modules together:

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    subgraph FunctionBased["<strong>Function-Based</strong>"]
        direction TB
        ModUi1("<strong>mod_inputs_ui(id)</strong>")
        ModServ1("<strong>mod_inputs_server(id)</strong>")
        React1[/"reactive list"/]
    end
    
    style FunctionBased color:#000000,stroke:#999

    subgraph R6Based["<strong>R6 Class</strong>"]
        direction TB
        New["<strong>ModInputs$new(id)</strong>"]
        UiMethod["<strong>$ui()</strong>"]
        ServMethod["<strong>$server()</strong>"]
        React2[/"reactive list"/]
    end
    
    style R6Based color:#000000,stroke:#4CBB9D

    subgraph Wire["<strong>Wiring Downstream</strong>"]
        PassReact(["Pass reactive to<br>next module"])
    end

    ModUi1 -->|"separate<br>functions"| ModServ1
    ModServ1 -->|"returns"| React1

    New -->|"creates<br>instance"| UiMethod
    New -->|"creates<br>instance"| ServMethod
    ServMethod -->|"returns"| React2

    React1 --> PassReact
    React2 --> PassReact

    style ModUi1 fill:#FFF8E7,font-family:monospace
    style ModServ1 fill:#FFF8E7,font-family:monospace
    
    
    style New fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style UiMethod fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style ServMethod fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style React1 fill:#FFF8E7,color:#000000,stroke:#999
    style React2 fill:#E0F0ED,color:#000000,stroke:#4CBB9D
    

We now know there are two main patterns for building Shiny modules: 1) traditional function-based modules, or 2) R6 classes. Both patterns work, our choice should depend on the complexity of the app state (or how many developers will be maintain the source code).

Reactivity

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

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

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

Then in ModOutputs, we consume that reactive list:

hide/show ModOutputs server reactive list
server = function(inputs_r) {
  moduleServer(private$id, function(input, output, session) {
    prices_r <- eventReactive(inputs_r()$fetch, {
      inp <- inputs_r()
      get_stock_prices(
        tickers = inp$tickers,
        from = inp$from,
        to = inp$to
      )
    })
  })
}
1
Downstream modules receive inputs_r and call it as a function

This pattern keeps the dependency graph explicit and testable.

Documenting R6 classes

As an app-package scales, teammates and users need to understand the module interfaces. Documenting R6 classes with roxygen2 makes ?ModInputs a complete reference; without it, users have to read the source code. Modern roxygen2 (7.0.0+) handles R6 seamlessly by letting us document the class structure and each method inline.

Class-level documentation

Start with roxygen comments above the R6Class() call:

hide/show ModInputs roxygen block
#' ModInputs: Shiny Input Module
#'
#' @description
#' Encapsulates input controls for stock ticker selection, date range, and 
#' analysis parameters.
#'
#' @details
#' Bundles the UI and server logic for a reusable inputs module. Methods 
#' handle namespace management automatically to prevent ID conflicts across
#' module instances. The private fields `id` and `ns` are managed internally;
#' callers interact only via public methods.
#'
#' @examples
#' if (interactive()) {
#'   inputs <- ModInputs$new(id = "inputs")
#'   # Note: server() only runs inside moduleServer() or shinyApp()
#' }
#'
#' @export
ModInputs <- R6::R6Class(
  "ModInputs",
  # ...
)
1
Title and class name
2
roxygen tags; every line must follow a tag (no plain text intro allowed)

Method documentation

Document each public method inline, right before the method definition. Methods get their own subsection in the generated help page.

hide/show ModInputs methods
public = list(

  #' @description
  #' Initialize the module with a namespace ID.
  #' @param id Character scalar; the module's namespace identifier.
  #' @return A new `ModInputs` object.
  initialize = function(id = "inputs") {
    private$id <- id
    private$ns <- NS(id)
  },

  #' @description
  #' Build the module's UI.
  #' @return HTML tags with namespaced input elements.
  ui = function() {
    sidebar(
      selectizeInput(
        inputId = private$ns("tickers"),
        label = "Tickers",
        choices = default_tickers(),
        multiple = TRUE
      ),
      # ...
    )
  },

  #' @description
  #' Start the server-side reactive logic.
  #' @return A reactive list containing user selections.
  server = function() {
    moduleServer(private$id, function(input, output, session) {
      reactive(list(
        tickers = input$tickers,
        dates = input$dates,
        vol_window = input$vol_window
      ))
    })
  }
)
1
Each method starts with @description; remaining tags follow

R6 help pages

When we run devtools::document(), roxygen2 parses the #' blocks and generates the .Rd files with separate subsections for the class description, each public method, and examples. The help page becomes the module’s contract.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'monospace', "darkMode":true}}}}%%

flowchart TD
    subgraph Source["<strong>R/ModInputs.R</strong>"]
        ClassBlock["Class block<br/><code>@description @details @export</code>"]
        MethodBlock["Inline method blocks<br/><code>@description @param @return</code>"]
    end
    Document(["<strong>devtools::document()</strong>"])
    Rd["<strong>man/ModInputs.Rd</strong><br/><em>Methods section,<br/>one subsection per method</em>"]
    Namespace["<strong>NAMESPACE</strong><br/><code>export(ModInputs)</code>"]
    Help(["<strong>?ModInputs</strong><br/><em>and pkgdown site</em>"])

    ClassBlock --> Document
    MethodBlock --> Document
    Document --> Rd
    Document --> Namespace
    Rd --> Help
    Namespace --> Help

    style ClassBlock fill:#FFF8E7,color:#000000,stroke:#999,font-family:monospace
    style MethodBlock fill:#FFF8E7,color:#000000,stroke:#999,font-family:monospace
    style Document fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace
    style Rd fill:#FFFFFF,color:#000000,stroke:#333,font-family:monospace
    style Namespace fill:#FFFFFF,color:#000000,stroke:#333,font-family:monospace
    style Help fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5,font-family:monospace

Packaging best practices

File organization matters; I use CamelCase names for R6 classes and mod_* names for helper functions. I also prefer to place each class in its own file, one per module.

In rsixer, the module files contain only the R6 class definition:

R/ModInputs.R    # ModInputs class (one per file)
R/ModOutputs.R   # ModOutputs class
R/ModDownload.R  # ModDownload class
R/app_server.R   # Wires modules together; instantiates objects
R/app_ui.R       # Calls $ui() methods on each instance

Export the classes with @export so package users can instantiate them. Only instantiate R6 objects inside app_server() or module server functions; never at package load time.

Tests

Writing tests with testthat is especially valuable for R6 modules. It’s a good idea to test that instantiation works, the methods return expected types, and that passing different id values produces properly isolated namespaces:

test_that("ModInputs instantiates with correct namespace", {
  mod <- ModInputs$new(id = "test_inputs")
  expect_equal(mod$.__enclos_env__$private$id, "test_inputs")
  expect_is(mod$ui(), "shiny.tag")
})

With roxygen2 and R6 together, teammates can run ?ModInputs and immediately understand what the class does, the methods that are available, what parameters they accept, and what they return. That’s the power of documentation over diving into the source .R file.

Recap

R6 classes provide a clean, testable way to organize Shiny app-packages. By bundling UI generation and server logic into a single object, we gain encapsulation, reduce global state, and make dependencies explicit. This is a pattern that can scale, too. I’ve shown that the rsixer app implements the same functionality of tooltipexplorer using R6 modules for inputs, outputs, and downloads, each passing reactive values to the next in a clear data flow.

Documenting R6 classes with roxygen2 transforms them from source code to a shareable API. Teammates can see the interface because the code itself is self-documenting, so the architecture scales with the size of the team.

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

For working examples, see the source code and structure of rsixer (mjfrigaard.github.io/rsixer) and tooltipexplorer (mjfrigaard.github.io/tooltipexplorer) side-by-side.