Building Custom Modules

Introduction

The modules in VizModules are designed to be composed and extended. You can build higher-level modules that add custom logic such as data filtering, transformations, or additional UI controls while reusing the full functionality of the base modules.

This vignette demonstrates how to create a custom module by building on top of the scatterPlot module.

The Pattern

When building a custom module, you need to handle Shiny’s namespacing correctly. The key insight is:

  1. Your module’s custom inputs need to be namespaced with NS(id).
  2. The base module’s UI and server functions should receive the bare id, not a namespaced version.
  3. Data processing that requires access to your module’s inputs must happen inside a moduleServer() block.
  4. The base module’s server should be called outside that moduleServer() block to avoid double-namespacing issues.

A Minimal Example

Let’s build a custom module that adds a simple filtering checkbox to the scatterPlot module.

The UI

library(VizModules)

minimalModuleUI <- function(id) {
    ns <- NS(id)
    tagList(
        h4("Minimal Module Controls"),
        # Custom input - uses the module's namespace
        checkboxInput(ns("filter_setosa"), "Start with Setosa Only", value = FALSE),
        hr(),
        # Base module UI - pass the bare 'id', not ns(id)
        dittoViz_scatterPlotInputsUI(id, iris)
    )
}

minimalModuleOutput <- function(id) {
    # Simply delegate to the base module's output UI
    dittoViz_scatterPlotOutputUI(id)
}

Notice that checkboxInput() uses ns("filter_setosa") to namespace the custom input, while dittoViz_scatterPlotInputsUI() receives the bare id. This ensures the base module creates its inputs in the correct namespace.

The Server

minimalModuleServer <- function(id, data_reactive) {
    # Step 1: Process data inside a moduleServer block
    # This gives us access to inputs namespaced to 'id' (our module's inputs)
    filtered_data <- moduleServer(id, function(input, output, session) {
        reactive({
            req(data_reactive())
            df <- data_reactive()

            # Input specific to this custom module
            if (isTRUE(input$filter_setosa)) {
                if ("Species" %in% names(df)) {
                    df <- df[df$Species == "setosa", ]
                }
            }
            df
        })
    })

    # Step 2: Call the base module server OUTSIDE the moduleServer block
    # This is critical! If we called this inside the moduleServer above,
    # dittoViz_scatterPlotServer would look for inputs at id-id-inputName instead of id-inputName
    dittoViz_scatterPlotServer(id, filtered_data)
}

Why this pattern?

Putting It Together

ui <- fluidPage(
    titlePanel("Minimal Module Example"),
    sidebarLayout(
        sidebarPanel(
            minimalModuleUI("demo")
        ),
        mainPanel(
            minimalModuleOutput("demo")
        )
    )
)

server <- function(input, output, session) {
    # Pass a reactive data source to the module
    minimalModuleServer("demo", reactive({
        iris
    }))
}

shinyApp(ui, server)

Hiding Base Module Inputs

If your module pre-sets certain parameters, you can hide those inputs from the user to keep them from being changed:

focusedModuleUI <- function(id) {
    ns <- NS(id)
    tagList(
        h4("Simplified Scatter Plot"),
        # The UI takes no hide.* arguments -- see the server below.
        dittoViz_scatterPlotInputsUI(id, iris)
    )
}

focusedModuleServer <- function(id, data_reactive) {
    # hide.inputs and hide.tabs are arguments of the *Server*, not the *InputsUI.
    dittoViz_scatterPlotServer(id, data_reactive,
        hide.inputs = c("shape.by", "color.by")
    )
}

Driving Base Module Inputs From Your Wrapper

Hiding an input fixes it to one value. When a base module parameter instead needs to follow something your wrapper computes, pass a reactive() as that entry of defaults rather than reaching for update*Input(). The key must name an input the module actually reads – get_default() falls back silently, so a defaults entry for an unexposed key does nothing at all. (main is the one to watch: no module exposes a plot title, so defaults = list(main = ...) has no effect.)

colouredModuleServer <- function(id, data_reactive) {
    colour_col <- moduleServer(id, function(input, output, session) {
        reactive(if (isTRUE(input$filter_setosa)) "" else "Species")
    })

    # The colour mapping tracks the checkbox, but the user can still change it.
    dittoViz_scatterPlotServer(
        id, data_reactive,
        defaults = list(color.by = colour_col)
    )
}

This is the recommended parent-to-child channel for parameter values. It resolves server-side, so the plot renders once per change instead of twice, and the control stays user-editable. See vignette("defaults-and-hiding", package = "VizModules") for the full semantics.

Automatically Preserving Manual Title/Legend/Annotation Edits

The base modules let users interactively drag and edit the plot title, legend, annotations, draggable axis titles, and continuous-colour legend (colorbar), and they re-apply those manual tweaks across re-renders. When you wrap a base module (as in the examples above), this persistence is inherited for free.

If you build a brand-new plotly output from scratch inside a custom module (rather than delegating to a base module’s server), you can add the same behaviour simply with 3 basic steps:

customPlotServer <- function(id, data_reactive) {
    moduleServer(id, function(input, output, session) {
        # 1. A unique event source + the edit store, created once.
        plot_source <- session$ns("customplot")
        edit_store <- setup_manual_edits(input, session, plot_source)

        output$plot <- renderPlotly({
            # 2. Create your plotly object with your plotting function
            fig <- build_my_plotly_figure(data_reactive(), input)
            # 3. Restore + capture edits on every render, then return the figure.
            finalize_manual_edits(fig, plot_source, edit_store, session)
        })
    })
}

See the “Adding a New Module” vignette for the full description of these helpers, but this is pretty much all you need to do.

Best Practices

  1. Keep wrapper logic focused: Each wrapper should add a cohesive set of related functionality.

  2. Document the data requirements: If your wrapper expects certain columns, a specific class or data structure, etc, document this clearly.

  3. Use reactive expressions: Use reactive data inputs.

  4. Test the namespace: If inputs aren’t working, check that you’re handling namespaces correctly. A common symptom of namespace issues is that inputs seem to have no effect.

  5. Consider composability: Design your wrappers so they could potentially be wrapped by even higher-level modules.

  6. Prefer reactive defaults over update*Input(): To drive a base module parameter from your wrapper’s state, pass a reactive() in defaults. Pushing values with update*Input() costs an extra render on every change. When you must update one of your own inputs from the server, call freezeReactiveValue() on it first so readers pause instead of rendering with the stale value. Freezing does not cover an input you rebuild with renderUI(), though. In those cases, resolve the value server-side and have the plot read that instead, as setup_group_colors() does for a colour picker (see the “Updating Your Own Inputs From the Server” section in vignette("adding-a-new-module", package = "VizModules") for more info).

See any of the existing modules for clear reference examples.