install.packages("R6")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:
- What
R6is and why it shines in Shiny app-packages - Defining
R6classes for Shiny modules (UI and server) - Wiring
R6objects 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:
R6 objects are used throughout the R ecosystem:
plumber2usesR6to organize REST API routers and middleware; each route handler is a method bound to anR6object, making routing logic reusable and testableshinytest2usesR6for app testing objects; theAppDriverclass 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:
- Bundling UI generation with business logic (module as a unified object)
- Managing state tied to a user session or module instance
- 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(), andmod_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)andmod_download_server(id)functions in one file
- 5
-
Function-based inputs module; contains
mod_inputs_ui(id)andmod_inputs_server(id)returning a reactive list of user selections
- 6
-
Function-based outputs module; consumes
inputs_rand renders performance metrics and charts
- 7
-
Helper that wraps hover/tooltip text for
reactabletable 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
loggerthreshold 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
-
reactabletable 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
R6module 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 eachR6instance 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
idinternally (equivalent tomod_download.Rintooltipexplorer)
- 5
-
R6 class bundling inputs UI and server logic; returns reactive list from
$server()method (equivalent tomod_inputs.Rintooltipexplorer)
- 6
-
R6 class bundling outputs UI and server logic; consumes reactive inputs from
ModInputs(equivalent tomod_outputs.Rintooltipexplorer)
- 7
-
Helper that wraps hover/tooltip text for
reactabletable cells (identical totooltipexplorer)
- 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
loggerthreshold for the app (identical totooltipexplorer)
- 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 totooltipexplorer)
- 4
-
reactabletable theme utilities for consistent table styling (identical totooltipexplorer)
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 usesprivate$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
-
privatemembers 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
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_randperf_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
inputsandinputs_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_rand 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
-
roxygentags; 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 instanceExport 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.