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:
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... | Parent | Server signature |
|---|---|---|
| Loads from API / file / database | new_data_block() | function(id) |
| Reshapes one upstream input | new_transform_block() | function(id, data) |
| Joins two inputs | new_join_block() | function(id, x, y) |
| Takes N inputs | new_variadic_block() | function(id, ...args) |
| Renders a plot | new_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:
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:
expris a quoted call, not a string. Useblockr.core::bbquote()with.(x)splices and pair it withexpr_type = "bquoted"on the parent constructor. Splice the upstream data via.(data).stateis 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
expronly 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/...argsare 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/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 value | Meaning |
|---|---|
FALSE (default) | Block state is read-only from outside. |
TRUE | All 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
- rblock starter package: this page's example as a complete package with tests and demo board
- blockr.docs patterns: canonical R-driven and JS-driven references
- Full create-block vignette: detailed walkthrough with advanced examples
- Block registry vignette: registry system details
- Extend blockr vignette: plugins and custom UI