Skip to contents

Creating blocks

Note: If you are not already familiar with Shiny modules, it is recommended you familiarise yourself with them before continuing with this section.

blockr.core is built on top of Shiny. At it’s heart, a block is nothing more than a specialised Shiny module that returns two additional values:

  • An expression (called “expr”), a reactive expression defining a blocks computation. This allows R code to be exported and data analyses to be recreated, outside of blockr and a reactive context.
  • A state object (called “state”), a list of reactive values tracking user inputs. This allows blocks to separate user-defined state from inputs specified by blocks.

flowchart LR
  subgraph block[Block]
    subgraph ctor[constructor]
      block_ui[UI]
      subgraph block_server[server]
        blk_state[State]
        blk_expr[Expression]
      end
    end
  end

While blocks return the “expr” and “state” values, blocks consist of three elements:

  1. A UI function to define the user interface.
  2. A Server function to handle reactive logic that returns an expression and state object.
  3. A Constructor function wraps the UI and server to intialize the block state.

Now, let’s go through each element, building up a block template as we go.

UI function

A UI function in blockr is the same as a UI function in a Shiny module. This means that:

  • The UI function signature is expected to contain a single id argument, which can be used with shiny::NS() to construct namespaced IDs.
  • A call to appropriate shiny UI functions is expected that return shiny.tag or shiny.tag.list objects, typically via the use of shiny::tagList() in the UI to list separate UI elements.

Let’s start to build our block template:

ui <- function(id) {
  tagList(
    # Wrap widgets in `tagList()`
    textInput(
      NS(id, "my_input") # Use `shiny::NS()` to construct namespaces
    )
  )
}

Server function

As inputs, a server function takes an id and any additional inputs from other blocks (e.g., data). For example, for the blocks shipped with blockr.core, this would result in zero inputs for a data block, one for a transform block such as a select block, two for a join block and the special argument ...args for variadic blocks, such as an rbind block.

flowchart TB
  data_blk[data block 1]
  data_blk_2[data block 2]
  data_blk_3[data block 3]
  data_blk_4[data block 4]
  select_blk[select block]
  join_blk[join block]
  rbind_blk[rbind block]
  data_blk --> |data| select_blk
  data_blk_2 -->|data1| join_blk
  data_blk_3 --> |data2| join_blk

  data_blk --> |1| rbind_blk
  select_blk --> |2| rbind_blk
  data_blk_4 --> |3| rbind_blk
  join_blk --> |4| rbind_blk

A server function should return as ouput a moduleServer() call, defining:

  • expr: A quoted reactive expression representing the block’s computation. Data names should match between expression and the top-level server function arguments.
  • state: A list of reactive values tracking user selections. The set of returned values from the state should match (both in count and names) that of the constructor signature.
server <- function(id, data) {
  moduleServer(id, function(input, output, session) {
    # Reactive logic goes here

    # Return a list with "expr" and "state"
    list(
      expr = reactive(quote(identity(data))),
      state = list(
        input_one = reactive(input_one()),
        input_two = reactive(input_two())
      )
    )
  })
}

Many options exist to build expressions, such as using tools offered by the rlang package or by passing a string to base::parse(). As a block developer you are free to choose the tools that your prefer, as long as the evaluated expression returns non-reactive R code which can be executed outside of blockr.

The state is needed for serialisation and deserialisation to respectively save and restore the state of a blockr application.

Constructor function

The constructor function wraps the UI and server functions and initializes the block. Note that both server and ui are closures and therefore may refer to names bound in the constructor scope (e.g. dataset and choices).

It should expose as arguments anything the user might set via the UI to control the block state. In other words, any inputs in the UI function, should have a corresponding argument in the constructor function. On the other hand, inputs from other blocks should not be exposed as arguments, as these are dynamically provided in the server function.

The return value should be a call to new_block() (or if applicable a call to the more specific virtual constructors new_data_block(), new_transform_block(), etc.).

We can finish our block template:

example_constructor <- function(ui_state = character(), ...) {
  ui <- function(id) { #nolint
    tagList(textInput(NS(id, "ui_state")))
  }

  server <- function(id, data) {
    moduleServer(id, function(input, output, session) {
      # Reactive logic goes here

      # Return a list with "expr" and "state"
      list(
        expr = reactive(quote(identity(data))),
        state = list(
          # name must match what is defined in the constructor signature
          ui_state = <STATE_VALUE>
        )
      )
    })
  }

  # Return call to `new_block()`
  new_block(
    server = server,
    ui = ui,
    class = "my_block",
    ...
  )
}

The ... is forwarded to new_block or any new_*_block.

Example

Putting this all together, we can create a utils::head() block, such as the one offered as new_head_block():

new_head_block <- function(n = 6L, ...) {
  new_transform_block(
    function(id, data) {
      moduleServer(
        id,
        function(input, output, session) {
          n_rows <- reactiveVal(n)

          observeEvent(input$n, n_rows(input$n))

          observeEvent(
            nrow(data()),
            updateNumericInput(
              inputId = "n",
              value = n_rows(),
              min = 1L,
              max = nrow(data())
            )
          )

          list(
            expr = reactive(
              bquote(utils::head(data, n = .(n)), list(n = n_rows()))
            ),
            state = list(
              n = n_rows
            )
          )
        }
      )
    },
    function(id) {
      tagList(
        numericInput(
          inputId = NS(id, "n"),
          label = "Number of rows",
          value = n,
          min = 1L
        )
      )
    },
    dat_val = function(data) {
      stopifnot(is.data.frame(data) || is.matrix(data))
    },
    class = "head_block",
    ...
  )
}

Here, the state return value is a list of length 1, containing the current value for the only constructor argument. The current value for n is represented by a shiny::reactiveVal(), n_rows(), which is initialized with the values n in the constructor scope and updated on every change to input$n. Furthermore the max value for the shiny::numericInput() field is updated on every change to the number of data rows.

A final block constructor argument of note might be dat_val, which is an optional function that can be passed which signals to the framework, if data passed to a block, can actually be processed by the block.

An app containing such a head block can be spun up as

serve(new_head_block(n = 10L), list(data = mtcars))

For an example with multiple data inputs, refer to examples such as new_merge_block(). Such a binary block (with arguments x and y) can be explored in a standalone app (with nonsensical inputs) as

serve(
  new_merge_block(by = "Time"),
  data = list(x = datasets::BOD, y = datasets::ChickWeight)
)

The data argument to serve() expects a list with names components that match the server function signature, i.e. data for new_head_block() and x, y for new_merge_block(). Such names can be chosen freely by the block implementer (with the exception of integer-valued names, such as `1`, `2`, etc. which are reserved as positional arguments in ...args).

More complex examples

The above example showed how to embed a typical R function into a block. However, blockr.core can do much more. What about creating an entire block around an existing complex Shiny module?

In the following, we expose how you could wrap a block around esquisse, a package to build ggplot without coding. esquisse exports standalone modules you can embed in any existing shiny app. Specifically, we consider esquisse_server() and esquisse_ui(). esquisse_server() needs some rectangular data as input and returns a list of 3 elements: modified data, code plot and filters code:

results <- esquisse::esquisse_server(
  id = "esquisse",
  data_rv = data
)

results$data
results$code_filters
results$code_plot

When filters are applied with esquisse, the returned data are filtered, otherwise the module returns the unchanged input data. Based on what we already know about constructing block, we can write the following code for the constructor server function. This block will have 1 input materialised as the data parameter (data coming from the upstream block):

esquisse_block_server <- function(id, data) {
  moduleServer(
    id,
    function(input, output, session) {
      results <- esquisse::esquisse_server(
        id = "esquisse",
        data_rv = data
      )
      list(
        expr = reactive({
          bquote(
            list(
              dat = as.data.frame(.(dat)),
              filters = .(filters)
            ),
            list(
              filters = results$code_plot,
              dat = results$data
            )
          )
        }),
        state = list()
      )
    }
  )
}

In the server module, we first call esquisse_server(). Then, the most technical part is to construct the returned value. We decide that the block is stateless, as in any case esquisse doesn’t handle starting the module in a specific state, so that would be useless to save something. The expression is composed of 2 elements, the data and plot code. By default, if we assume our esquisse block to be a transform_block, blockr.core uses block_output.transform_block to display the block output:

block_output.transform_block <- function(x, result, session) {
  session$output$result <- dt_result(result$dat, session)
}

This S3 method can only handle rectangular data and not a list as we specified in esquisse_block_server(). We therefore have to create a new class and constructor for our esquisse block, we call it complex_block and new_complex_block(), respectively:

new_complex_block <- function(server, ui, class, ctor = sys.parent(), ...) {
  new_block(server, ui, c(class, "complex_block"), ctor, ...)
}

As you may notice, new_complex_block() is very similar to new_transform_block, the only difference being the class name. We subsequently define a new S3 method for block_output(), which is able to process our list result (don’t forget the @export roxygen tag):

#' @export
block_output.complex_block <- function(x, result, session) {
  session$output$filters <- renderPrint(result$filters)
  # result must come at the end of the output list if you have multiple outputs
  session$output$result <- dt_result(result$dat, session)
}

We also provide the UI counter part. Here you could go for more fancy layout but for sake of simplicity, we design a minimalistic UI:

#' @export
block_ui.complex_block <- function(id, x, ...) {
  tagList(
    h1("Transformed data from {esquisse}"),
    DT::dataTableOutput(NS(id, "result")),
    verbatimTextOutput(NS(id, "filters"))
  )
}

The UI function of our constructor is simple, even though you would be totally free to customise it further:

esquisse_block_ui <- function(id) {
  tagList(
    esquisse::esquisse_ui(
      id = NS(id, "esquisse"), 
      header = FALSE # dont display gadget title
    )
  )
}

Finally our new esquisse block constructor given by:

new_esquisse_block <- function(...) {
  new_complex_block(
    server = esquisse_block_server,
    ui = esquisse_block_ui,
    class = "esquisse_block",
    dat_valid = NULL,
    allow_empty_state = TRUE,
    ...
  )
}

To test our new block we call:

serve(
  new_board(
    blocks = list(
      a = new_dataset_block(iris),
      b = new_esquisse_block()
    ),
    links = list(
      new_link("a", "b", "data")
    )
  )
)

As closing remark, since the esquisse block is stateless, save and restore won’t work for this block.

Note

The demo below runs with shinylive. Not all feature may work as expected due to compatibility issues with webR.