Skip to content

Create a block

Write custom blocks in pure R to extend blockr with your own logic. A block is a Shiny module that returns an expr (the R code it generates) and a state (its current input values). A workflow is a Shiny app composed of connected blocks.

Blocks should live in an R package so they can be registered, shared, and tested.

Just getting started?

The fastest way to your first block is to start from a starter package and change it, by hand or with a coding agent. See Create a custom block. Come back here when you want the technical reference.

Source of truth

The example on this page is the myplot block from the rblock starter package, which ships with passing tests and a demo board. Block patterns are documented canonically in blockr.docs; r-driven-blocks.md covers everything below in more depth, plus the JS-driven path for polished control UIs.

Block anatomy

Every block is built from a constructor that wires together a server function and a UI function, then forwards them to a typed parent constructor (new_data_block, new_transform_block, new_join_block, new_plot_block, or new_variadic_block).

The complete constructor, from the starter package:

r
new_myplot_block <- function(x = character(), y = character(), ...) {
  blockr.core::new_plot_block(
    server = function(id, data) {
      shiny::moduleServer(id, function(input, output, session) {
        x_col <- shiny::reactiveVal(x)
        y_col <- shiny::reactiveVal(y)

        shiny::observeEvent(input$xcol, x_col(input$xcol))
        shiny::observeEvent(input$ycol, y_col(input$ycol))

        shiny::observeEvent(colnames(data()), {
          cols <- colnames(data())
          shiny::updateSelectInput(
            session, "xcol", choices = c("Pick a column" = "", cols),
            selected = if (isTRUE(x_col() %in% cols)) x_col() else ""
          )
          shiny::updateSelectInput(
            session, "ycol", choices = c("Pick a column" = "", cols),
            selected = if (isTRUE(y_col() %in% cols)) y_col() else ""
          )
        })

        list(
          expr = shiny::reactive(make_myplot_expr(x_col(), y_col())),
          state = list(x = x_col, y = y_col)
        )
      })
    },
    ui = function(id) {
      shiny::tagList(
        shiny::selectInput(shiny::NS(id, "xcol"), "X axis (groups)",
                           choices = x, selected = x),
        shiny::selectInput(shiny::NS(id, "ycol"), "Y axis (values)",
                           choices = y, selected = y)
      )
    },
    class = "myplot_block",
    expr_type = "bquoted",
    allow_empty_state = TRUE,
    ...
  )
}

Constructor

The constructor exposes every UI-controllable parameter as an argument and forwards ... to the parent so framework options (class, allow_empty_state, expr_type, dat_valid, ...) can pass through.

Pick the parent based on the block's role:

Block does...ParentServer signature
Loads from API / file / databasenew_data_block()function(id)
Reshapes one upstream inputnew_transform_block()function(id, data)
Joins two inputsnew_join_block()function(id, x, y)
Takes N inputsnew_variadic_block()function(id, ...args)
Renders a plotnew_plot_block()function(id, data)

Expression builder

The code a block generates is best factored into a pure function, kept in its own file (R/expr-builders.R in the starter). It takes the user's choices and returns a quoted expression, so it can be unit-tested without Shiny:

r
is_set <- function(v) {
  is.character(v) && length(v) == 1L && nzchar(v)
}

make_myplot_expr <- function(x = character(), y = character()) {
  if (!is_set(x) || !is_set(y)) {
    # Unconfigured: render a friendly placeholder instead of erroring.
    return(
      bbquote(
        ggplot2::ggplot() +
          ggplot2::annotate(
            "text",
            x = 0, y = 0, label = "Pick x and y columns to draw the plot",
            color = "grey45", size = 5
          ) +
          ggplot2::theme_void()
      )
    )
  }

  bbquote(
    ggplot2::ggplot(
      .(data),
      ggplot2::aes(x = factor(.(x)), y = .(y), fill = factor(.(x)))
    ) +
      ggplot2::geom_boxplot(alpha = 0.6, outlier.shape = NA) +
      ggplot2::geom_jitter(width = 0.15, alpha = 0.5, size = 1.5) +
      ggplot2::labs(x = .(x_lab), y = .(y_lab)) +
      ggplot2::guides(fill = "none") +
      ggplot2::theme_minimal(base_size = 13),
    list(
      x = as.name(x),
      y = as.name(y),
      x_lab = x,
      y_lab = y
    )
  )
}

Rules that bite:

  • Build language objects, never strings. as.name() handles any column name, including ones with spaces. paste() plus parsing breaks on the first odd name.
  • Qualify every function (ggplot2::): the expression is evaluated outside your package's namespace.
  • An unconfigured block must not error. Return a placeholder (plot blocks) or a .(data) pass-through (transform blocks) until required inputs are set.

Server function

Wraps a shiny::moduleServer() and returns list(expr = ..., state = ...). The pattern: one reactiveVal per constructor argument, observers that copy inputs into them, and an observer that refreshes column choices when upstream data changes.

Rules that bite:

  • expr is a quoted call, not a string. Use blockr.core::bbquote() with .(x) splices and pair it with expr_type = "bquoted" on the parent constructor. Splice the upstream data via .(data).
  • state is a list of reactives, one per constructor parameter. Names must match constructor argument names exactly. Serialization breaks silently otherwise.
  • The expression must evaluate outside a reactive context. If expr only works because a reactive happens to be in scope, the export pipeline will fail.
  • Don't expose data inputs as constructor arguments. data / x / y / ...args are wired by the framework via the server signature.

UI function

A standard Shiny module UI taking id and returning shiny.tag objects. Initialise inputs with the constructor's defaults (not empty), so restored blocks render their saved state.

Registering your block

Register on package load so the block has metadata (without it, every constructor call emits a "No block metadata available" warning, and the block doesn't show up in board / AI / MCP discovery):

r
# R/zzz.R
.onLoad <- function(libname, pkgname) {
  blockr.core::register_blocks(
    ctor = "new_myplot_block",
    name = "My plot",
    description = "Boxplot with jittered points by group",
    category = "plot",
    package = pkgname
  )
}

category must be one of blockr.core::suggested_categories(): input, transform, structured, plot, table, model, output, utility, uncategorized. Data-fetching blocks are input, not data. register_blocks() is vectorised; one call can register several blocks.

External control (experimental)

The AI assistant and other controllers can write a block's state from outside its server. This is opt-in via external_ctrl on the parent constructor, and marked experimental in blockr.core:

external_ctrl valueMeaning
FALSE (default)Block state is read-only from outside.
TRUEAll constructor arguments are externally writable.
"x" (a string)Only the named state slot is externally writable.
c("x", "y")Multiple named slots are writable.

State names handed to external_ctrl must match the names in the server's state list. The framework validates writes by re-evaluating the block expression: if evaluation fails, the previous state is restored. See ?blockr.core::ctrl_block for the plugin that drives this.

JS-driven blocks built on the blockr.dplyr factory have this enabled already, including the sync back into the controls. For an R-driven block, enabling it is not enough on its own: stock inputs only change on user events, so add observers that push state changes back into the inputs (updateSelectInput() and friends), or external writes update the result while the controls keep showing the old values.

Further reading