We’ve all had this need.
A table with rows. On each row, an “Edit” button. A “Delete” button. Maybe a “View details” button. Basic stuff.
In Shiny, displaying a table is trivial: DT::datatable(). But the moment you want clickable buttons inside the cells, you run into a real architecture problem. Not a CSS or JavaScript problem. A Shiny reactivity problem.
I’m going to show you the path I’ve travelled over the past few years to arrive at the pattern I use today, the one that fits in a 15-line function, that is vectorized, that handles repeated clicks without bugs, and that we reuse across all our CRUD modules.
The instinct (that doesn’t work)
When you start out in Shiny, you think in terms of Shiny components: actionButton(), observeEvent(), done.
So for a table with 10 rows, you tell yourself: “I’ll create 10 actionButton(), one per row, each with a unique ID. Then I’ll have 10 observeEvent() on the server side.”
# UI: we generate one actionButton per row
for (i in seq_len(nrow(data))) {
output[[paste0("edit_", data$id[i])]] <- renderUI({
actionButton(
inputId = ns(paste0("edit_", data$id[i])),
label = "Edit"
)
})
}
# Server: one observeEvent per button
for (i in seq_len(nrow(data))) {
observeEvent(input[[paste0("edit_", data$id[i])]], {
# ... open the edit modal
})
}It works. With 10 rows, it’s even smooth.
Now try it with 200 rows. Or with a table whose number of rows changes with every filter.
Three problems show up immediately:
- You don’t know how many buttons you’ll have. The data comes from the database, the number of rows varies. You have to create the
observeEvent()dynamically, which is already a red flag. - The
observeEventare created in a loop, often inside anobserve()that re-triggers on every data change. You stack up observers that are never destroyed, you create reactivity leaks. - It’s slow. Each
actionButtonis a full Shiny input, with its JavaScript binding, its ID in the DOM, its server-side handling. Multiply that by the number of rows, by the number of button types (edit + delete + …), and the app drags.
This pattern is the one you find in 2018 Stack Overflow tutorials. It’s understandable. It’s intuitive. And it doesn’t scale.
The idea: one input, N buttons
The solution is to bypass Shiny’s native input system.
Instead of creating one actionButton() per row (a Shiny component with a unique ID), we generate a plain HTML <button> with an onclick that directly calls Shiny’s JavaScript API:
Shiny.setInputValue('my_input', 42);Shiny.setInputValue() is a JavaScript function exposed by Shiny. It lets you create or update any input from the browser, without going through the usual bindings.
Concretely, a complete button looks like this:
<button type="button" onclick="Shiny.setInputValue('my_input', 42)">
Edit
</button>The onclick attribute is a standard HTML attribute: it contains JavaScript code that the browser runs on every click of the button. Here, on every click, the browser calls Shiny.setInputValue('my_input', 42), which updates the my_input input on the server side with the value 42. No Shiny binding, no unique ID: just an HTML button that writes to an input when you click it.
So we can create a single input on the server side, and N HTML buttons that all write to that same input, each with a different value (the row ID).
# A single observeEvent for all the "edit" buttons
observeEvent(input$edit_row, {
row_id <- input$edit_row
# ... open the modal for row row_id
})No more loop. No more dynamically created observers. A single observeEvent, regardless of the number of rows in the table.
It seems perfect. Except there’s a catch.
The Shiny cache trap
Try clicking twice in a row on the “Edit” button of the same row.
The first time, Shiny.setInputValue('edit_row', 42) sends the value 42. The observeEvent fires. All good.
The second time, Shiny.setInputValue('edit_row', 42) sends… 42 again. Same value. And Shiny, being clever, thinks: “the value hasn’t changed, no need to notify the observers.”
Result: the second click does nothing.
This is Shiny’s default behavior. An input only notifies its dependencies when its value changes. If you re-send the same value, no notification.
There are two known solutions. The first, found in many tutorials, consists of resetting the input after each processing:
observeEvent(input$edit_row, {
row_id <- input$edit_row
# ... processing ...
# Reset via a custom JS message
session$sendCustomMessage("resetInputValue", ns("edit_row"))
})With the corresponding JavaScript handler:
Shiny.addCustomMessageHandler("resetInputValue", function(name) {
Shiny.setInputValue(name, null);
});It works. But it’s heavy: you have to register a custom message handler, never forget the reset in each observeEvent, and never forget to wrap the input with ns().
There’s a more elegant solution.
The nonce: the clean solution
Rather than resetting the input after each click, we make sure that each click sends a different value, even if it’s the same button.
The trick: instead of sending just the row ID, we send a JavaScript object with the ID and a nonce (a unique value generated on each click):
Shiny.setInputValue('edit_row', {
id: '42',
nonce: Date.now()
}, {priority: 'event'});Three ingredients in this line:
{id: '42', nonce: Date.now()}: The value sent to Shiny. It’s an object with two fields: id holds the row identifier (what we care about), and nonce holds the timestamp in milliseconds at the moment of the click. Since Date.now() returns a different number on every call (unless you click twice in under a millisecond), the input’s overall value is always different. Shiny sees a change and notifies the observers.
{priority: 'event'}: The third argument of setInputValue. By default, Shiny processes input changes with a “deferred” priority: they are batched and sent to the server at the end of the flush cycle. With priority: 'event', the change is sent immediately, like an event. That’s what guarantees the click is processed without perceptible delay. And it’s also what ensures that even an identical click in a very short interval is treated as a new event, not merged with the previous one.
On the server side, we retrieve the full object as an R list:
observeEvent(input$edit_row, {
row_id <- as.integer(input$edit_row$id)
# ... processing with row_id
})input$edit_row is a list with $id and $nonce. We only read $id; the nonce did its job (making the value unique) and we ignore it.
The utility function
Now that we have the mechanism, we need to generate the buttons’ HTML.
Here’s the function we use at Data Champ’:
#' Create an action button for a DataTable row
#'
#' Generates an HTML `<button>` that calls `Shiny.setInputValue` on click.
#' Vectorized over `value` via `sprintf`.
#'
#' @param input_id Namespaced input id (use `ns("edit_row")`).
#' @param value Row identifier (usually the primary key).
#' @param label Optional button label text.
#' @param icon Optional FontAwesome icon name (e.g. `"pen"`, `"trash"`).
#' @param class Full CSS class string for the button.
#' @return Character vector of HTML button strings.
create_table_action_button <- function(
input_id,
value,
label = NULL,
icon = NULL,
class = "btn btn-sm btn-outline-secondary"
) {
inner <- paste0(
if (!is.null(icon)) paste0('<i class="fa-solid fa-', icon, '"></i> ') else "",
if (!is.null(label)) label else ""
)
sprintf(
paste0(
'<button type="button" class="%s" ',
'onclick="Shiny.setInputValue(\'%s\', ',
'{id: \'%s\', nonce: Date.now()}, {priority: \'event\'})">',
'%s</button>'
),
class, input_id, value, inner
)
}A few design points:
Vectorized thanks to sprintf. The value parameter can be a vector (typically the id column of your data.table). sprintf natively iterates over vector arguments: one call, N buttons. No lapply, no loop.
inner precomputed once. The button content (icon + label) is the same for every row. We build it once before the sprintf, which avoids recomputing it for each row.
Full CSS class as a parameter. The class parameter takes the entire string ("btn btn-sm btn-outline-primary"). We could have automatically prefixed btn btn-sm and asked only for the variant ("btn-outline-primary"), but in practice, having the full class gives more flexibility when you want a button that breaks the mold (different size, custom style).
Icon and label are both optional. You can have a button with just an icon, just a label, or both. At our place, we generally use icons alone in the “actions” columns to save space.
input_id is namespaced by the caller. The function expects a ns("edit_row"), not a raw "edit_row". It’s the caller (the module) that knows its namespace, not the utility function.
Usage inside a module
Here’s what a complete CRUD module that uses this function looks like. It’s a real module pulled from our codebase, simplified for readability.
The renderDataTable
output$pillars_table <- DT::renderDataTable({
dt <- pillars_list()
display_dt <- dt[, .(id, label, target_percent)]
display_dt[, actions := paste0(
create_table_action_button(
ns("edit_row"), id,
icon = "pen", class = "btn btn-sm btn-outline-primary"
),
" ",
create_table_action_button(
ns("delete_row"), id,
icon = "trash", class = "btn btn-sm btn-outline-danger"
)
)]
display_dt[, id := NULL]
DT::datatable(
data = display_dt,
rownames = FALSE,
colnames = c("Pillar", "Target %", ""),
selection = "none",
escape = FALSE,
options = list(
dom = "t",
paging = FALSE,
ordering = FALSE,
columnDefs = list(list(
targets = 2L,
orderable = FALSE,
searchable = FALSE,
className = "dt-right"
))
)
)
})Let’s break it down.
Building the actions column. We use data.table to add an actions column to the display table. Each cell contains two buttons (edit + delete), separated by a space. The paste0() concatenates the two HTML strings.
The id column is used to generate the buttons (it’s the value sent on click), but we then drop it from the display table (display_dt[, id := NULL]) so we don’t show it to the user.
escape = FALSE is crucial. By default, DT::datatable escapes the HTML in cells (to prevent XSS injections). Here, we want our buttons to be rendered as HTML, not displayed as raw text. escape = FALSE disables escaping on all columns.
If you have columns that contain user data (free text, names, etc.) and you want to escape the HTML on those but not on the actions column, you can use a vector: escape = c(TRUE, TRUE, FALSE), one boolean per column, in order.
selection = "none" disables row selection by click. Without it, every click on a button would also select the row (blue highlight), which is visually confusing.
columnDefs targets the actions column (index 2, 0-based) to make it non-sortable, non-searchable, and right-aligned. It’s visual polish.
The observeEvent
On the server side, one observeEvent per action type:
# Edit
observeEvent(input$edit_row, {
row_id <- as.integer(input$edit_row$id)
dt <- pillars_list()
row <- dt[id == row_id]
req(nrow(row) == 1L)
showModal(modalDialog(
title = "Edit pillar",
textInput(ns("form_label"), "Label", value = row$label),
numericInput(
ns("form_target_percent"), "Target %",
value = row$target_percent, min = 0, max = 100, step = 1
),
footer = tagList(
modalButton("Cancel"),
actionButton(ns("form_submit"), "Save", class = "btn-primary")
)
))
})
# Delete (with confirmation)
observeEvent(input$delete_row, {
req(is.list(input$delete_row))
row_id <- as.integer(input$delete_row$id)
dt <- pillars_list()
row <- dt[id == row_id]
req(nrow(row) == 1L)
showModal(modalDialog(
title = "Confirm deletion",
p("Delete the pillar", strong(row$label), "?"),
footer = tagList(
modalButton("Cancel"),
actionButton(ns("confirm_delete"), "Delete", class = "btn-danger")
)
))
})The structure is identical for both:
as.integer(input$edit_row$id): we extract the row ID from the object.as.integer()becauseShiny.setInputValuepasses a JavaScript string, which we want to compare with an integer ID in the database.- We find the row in the reactive data, we check that it exists (
req(nrow(row) == 1L)). - We do something with it (open a modal, trigger a deletion, etc.).
Two action types, two observeEvent, regardless of the number of rows in the table.
What to take away
This pattern is nothing revolutionary. It’s basic JavaScript (onclick + Shiny.setInputValue), combined with a deduplication trick (the nonce) and a bit of sprintf for vectorization.
But it’s one of those patterns that, once codified in a 15-line utility function, eliminates an entire category of bugs: phantom observers, reactivity leaks, ignored clicks, forgotten resets.
At our place, create_table_action_button lives in the helpers_ui.R of every Shiny project that has a DataTable with per-row actions. And we’ve documented it in our Cursor rules so the AI uses it systematically instead of reinventing a shaky pattern in every module.
If you have questions or variations of this pattern, share them in the comments.
The newsletter
If this kind of Shiny pattern pulled from real production projects interests you, I share regularly in my newsletter. That’s where I publish these field notes before they become articles.
Comments