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))
# Refresh column choices when upstream data changes, keeping the
# current selection if it still exists. isTRUE() covers the
# zero-length startup default; a vanished column resets to ""
# (unset), which pauses evaluation again.
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",
# x and y are required state (allow_empty_state defaults to FALSE):
# the framework holds evaluation until both columns are chosen.
...
)
}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:
make_myplot_expr <- function(x, y) {
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. - Let the framework gate the unconfigured state. Required state (anything not listed in
allow_empty_state) holds evaluation: the builder is never called with empty inputs, the block shows a "set this block's inputs" note, and code export waits for it. Don't guard for empty values in the builder, and don'tstop()there either — a throw while building the expression escapes the framework's error handling, unlike a failure while evaluating it, which is caught and displayed cleanly.
Quoting with bbquote()
blockr.core::bbquote() is what builds that quoted call. It takes the same arguments as base R's bquote() (expr, where, splice), uses the same .(x) and ..(x) markers, and returns the same kind of language object. The single difference: base bquote() stops when a marked name cannot be found, while bbquote() leaves it in the expression as a literal .(x).
n <- 10L
bquote(utils::head(.(data), n = .(n)), list(n = n))
#> Error in eval(e[[2L]], where) : object 'data' not found
bbquote(utils::head(.(data), n = .(n)), list(n = n))
#> utils::head(.(data), n = 10L)The full rationale lives in ?blockr.core::bbquote.
Why blocks need it
A block expression is filled in twice, in two different places.
- In the block server,
bbquote()substitutes the user's choices:n, the selected columns, the axis labels. The data marker.(data)has to survive this step, because a block does not know which upstream it will be wired to. - In the framework,
.(data)is replaced by the block that is actually linked. During a session that substitution happens in the evaluator; on code export it happens while assembling the script.
That second step is what expr_type = "bquoted" on the parent constructor switches on. Without it the framework never resolves the markers and .(data) reaches evaluation unresolved.
The payoff is the exported code. A "bquoted" block substitutes the upstream block id straight into the call:
local(utils::head(csv_block, n = 10L))The default expr_type = "quoted" keeps a bare data in the expression and has to bind it around the call:
with(list(data = csv_block), utils::head(data, n = 10L))Both evaluate to the same result. The first one is what a person would have typed, which is the point of the code export.
How to use it
- Mark every hole with
.(), and supply the values inwhere, a named list (or an environment) whose names match the markers. - Pass language objects, not strings. A column name goes in as
as.name(x)so it prints asmpg; a value meant to stay a string (an axis label, a file path) goes in as-is. - Leave the data markers out of
where. Write.(data)in a transform or plot block,.(x)and.(y)in a join block. The names must match the server function's arguments. - For a variadic block, build one marker per input and splice them in:
bbquote(
rbind(..(dat)),
list(dat = lapply(arg_names(), function(nm) call(".", as.name(nm)))),
splice = TRUE
)
#> rbind(.(a), .(b))- If
R CMD checkreports "no visible global function definition for.", import the markers with@importFrom blockr.core . ... Both are exported for that purpose and throw if actually called. - In unit tests the framework is not there to run step 2, so resolve
.(data)yourself. See Testing blocks.
Alternatives
| Approach | Verdict |
|---|---|
bbquote() | Recommended. Handles both steps of the substitution with one notation. |
base bquote() | Works only if you re-quote the data markers by hand: bquote(utils::head(.(data), n = .(n)), list(n = n, data = quote(.(data)))). Every marker you forget is an error at build time, and the list has to be kept in sync with the expression. |
rlang expr() with !! / !!! | Works: rlang only acts on !!, so rlang::expr(utils::head(.(data), n = !!n)) leaves .(data) untouched. It costs an rlang dependency and puts two quoting notations in one expression. |
expr_type = "quoted" with a bare data | No unquoting of the data at all, quote() or bquote() is enough. Still supported and used by older blocks, at the price of the with() wrapper in exported code. |
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)markers and pair it withexpr_type = "bquoted"on the parent constructor, as described in Quoting withbbquote().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
bbquote()reference: the two-step quoting rationale, from blockr.core itself- Full create-block vignette: detailed walkthrough with advanced examples
- Block registry vignette: registry system details
- Extend blockr vignette: plugins and custom UI