Interactive Tables with reactable
The reactable package builds interactive data tables for R using React. These tables run in R Markdown documents, Shiny applications, and standalone HTML pages. Reactable tables support sorting, filtering, pagination, row selection, and custom cell rendering without requiring JavaScript knowledge.
installation
Install reactable from CRAN:
install.packages("reactable")
You also need the htmlwidgets package for rendering:
install.packages("htmlwidgets")
For advanced formatting features, install the reactablefmtr package:
install.packages("reactablefmtr")
basic-usage
Create a basic interactive table with the reactable() function:
library(reactable)
# Sample data
sample_data <- data.frame(
name = c("Alice", "Bob", "Charlie", "Diana", "Eve"),
age = c(28, 35, 42, 31, 29),
score = c(85, 92, 78, 88, 95),
department = c("Engineering", "Marketing", "Sales", "Engineering", "HR")
)
# Create basic table
reactable(sample_data)
This renders a table with sortable columns, a search box, and pagination controls. Click any column header to sort. Use the search box to filter rows.
column-definitions-and-formatting
Control column behavior with colDef() within columns argument:
reactable(
sample_data,
columns = list(
name = colDef(name = "Employee Name"),
age = colDef(
name = "Age",
format = colFormat(suffix = " years")
),
score = colDef(
name = "Performance Score",
format = colFormat(digits = 1),
style = function(value) {
color <- if (value >= 90) "#2ecc71" else if (value >= 80) "#f39c12" else "#e74c3c"
list(color = color)
}
),
department = colDef(
name = "Department",
filterable = TRUE,
cell = function(value) {
paste0("📁 ", value)
}
)
)
)
Column definitions accept:
name: Display namewidth: Column width in pixelsalign: Alignment (“left”, “center”, “right”)format: Number/date formattingsortable: Enable/disable sortingfilterable: Enable/disable filteringresizable: Allow column resizecell: Custom cell renderer
sorting-and-filtering
Enable sorting on specific columns:
reactable(
sample_data,
sortable = TRUE,
defaultSorted = "score",
defaultSortOrder = "desc",
columns = list(
name = colDef(sortable = TRUE),
age = colDef(sortable = TRUE),
score = colDef(sortable = TRUE),
department = colDef(sortable = TRUE)
)
)
Add column-specific filters:
reactable(
sample_data,
filterable = TRUE,
columns = list(
department = colDef(
filterMethod = JS("function(rows, columnId, filterValue) {
return rows.filter(function(row) {
return row.values[columnId].toLowerCase().includes(filterValue.toLowerCase())
})
}")
)
)
)
The default filter performs case-insensitive substring matching. Use custom JavaScript functions for advanced filtering logic.
pagination
Control pagination with these options:
reactable(
sample_data,
pagination = TRUE,
pageSize = 10,
showPageSizeOptions = TRUE,
pageSizeOptions = c(5, 10, 25, 50),
defaultPage = 1
)
pagination: Enable pagination (default: TRUE for >10 rows)pageSize: Default rows per pageshowPageSizeOptions: Display page size selectorpageSizeOptions: Available page sizesdefaultPage: Initial page number
For client-side pagination, all data loads at once. For server-side pagination in Shiny, use remote() option and handle page events in your server function.
custom-cell-rendering
Render custom cell content with the cell parameter:
# Custom cell with progress bar
reactable(
sample_data,
columns = list(
score = colDef(
cell = function(value) {
# Create progress bar
width <- paste0(value, "%")
bar <- htmltools::tags$div(
style = list(width = width, background = "#3498db", height = "100%"),
class = "progress-bar"
)
container <- htmltools::tags$div(
style = list(width = "100%", background = "#ecf0f1", height = "20px"),
bar
)
htmltools::tagList(container, paste(value, "points"))
}
)
)
)
Use htmltools to build complex HTML elements within cells. This approach works for embedding:
- Images and icons
- Links and buttons
- Progress bars and meters
- Nested tables
- Any HTML content
conditional-styling
Apply conditional formatting based on cell values:
reactable(
sample_data,
columns = list(
score = colDef(
style = function(value) {
# Color based on score ranges
color <- case_when(
value >= 90 ~ "#27ae60",
value >= 80 ~ "#2980b9",
value >= 70 ~ "#f39c12",
TRUE ~ "#c0392b"
)
list(color = color, fontWeight = "bold")
}
),
age = colDef(
style = function(value) {
# Highlight ages above 35
if (value > 35) {
list(background = "#ffe6e6")
} else {
list(background = "white")
}
}
)
)
)
Apply row-level styling with the rowStyle option:
reactable(
sample_data,
rowStyle = function(index) {
if (sample_data$score[index] < 80) {
list(background = "#fff5f5")
}
}
)
interactive-features
Reactable tables support user interaction beyond basic sorting and filtering:
Row selection
reactable(
sample_data,
selection = "multiple",
onClick = "select",
rowClass = function(index) {
if (index %% 2 == 0) "even-row"
}
)
Expandable rows
reactable(
sample_data,
details = function(index) {
details_data <- sample_data[index, ]
htmltools::tags$div(
htmltools::tags$h4("Additional Details"),
htmltools::tags$p(paste("Employee ID:", 1000 + index)),
htmltools::tags$p(paste("Start Date:", Sys.Date() - sample_data$age[index] * 365))
)
}
)
Embed in Shiny
Use reactable in Shiny applications:
library(shiny)
ui <- fluidPage(
titlePanel("Employee Dashboard"),
reactableOutput("table")
)
server <- function(input, output) {
output$table <- renderReactable({
reactable(
sample_data,
columns = list(
name = colDef(cell = function(value) {
htmltools::tags$a(href = paste0("/employee/", value), value)
})
),
searchable = TRUE
)
})
}
shinyApp(ui, server)
see-also
- Filter Data with dplyr, Learn to filter data frames before rendering tables
- Select Columns with dplyr, Choose specific columns for display
- Sort Data with dplyr, Order data before table rendering
Custom cell rendering
reactable’s colDef(cell = ...) argument accepts a JavaScript function as a string or an R function. In R, colDef(cell = function(value, index) htmltools::tags$strong(value)) wraps the cell content in bold. colDef(cell = JS("function(cellInfo) { return cellInfo.value > 100 ? '<b>' + cellInfo.value + '</b>' : cellInfo.value }")) uses JavaScript for client-side rendering without round-tripping to the server.
Aggregation
When a reactable has grouped rows, aggregate in colDef() specifies how grouped values are summarized: aggregate = "sum" sums numeric values, aggregate = "count" counts non-null values, aggregate = "mean" averages them. Custom aggregation with a JavaScript function: aggregate = JS("function(values) { return Math.max.apply(null, values) }"). Aggregation runs entirely in the browser, no server round-trip needed.
Integration with Shiny
reactable::reactableOutput() and renderReactable() integrate with Shiny. getReactableState("tableId", "selected") retrieves the currently selected rows as a reactive, enabling drill-down workflows. updateReactable("tableId", data = new_data) updates the table data without re-rendering the full UI. For large datasets, enable server-side pagination with reactable() wrapping a shiny::eventReactive that fetches only the requested page.
Theming
reactable integrates with bslib for Bootstrap-based theming. Set theme = reactableTheme(backgroundColor = "#f9f9f9", borderColor = "#ddd") for custom colors. The style argument on colDef() accepts CSS strings for per-column styles. For dark mode support, bslib::bs_theme() propagates Bootstrap variables to reactable automatically when used in a themed Shiny app.
Searching and filtering
reactable(df, searchable = TRUE) adds a global search box that filters all columns. filterable = TRUE adds per-column filter inputs. To filter specific columns only, set colDef(filterable = TRUE) for selected columns and leave others unset. For custom filter logic, colDef(filterMethod = JS("function(rows, columnId, filterValue) {...}")) defines a JavaScript filter function. Combine with defaultPageSize = 20 and pagination to handle tables with thousands of rows efficiently.
Interactive tables with reactable
reactable creates interactive HTML tables with sorting, filtering, and pagination. It renders as an htmlwidget in R Markdown, Quarto, and Shiny. The default output is clean and usable without customization.
reactable(df) renders a basic interactive table. searchable = TRUE adds a global search box. filterable = TRUE adds per-column filter inputs. sortable = TRUE (default) allows column sorting by clicking headers. pagination = FALSE shows all rows at once (careful with large data). defaultPageSize = 25 sets rows per page.
columns = list(col_name = colDef(...)) customizes individual columns. colDef(name = "Display Name", format = colFormat(digits = 2, percent = TRUE), minWidth = 80, align = "right") sets column display options. colDef(show = FALSE) hides a column from display while keeping it in the data for use in other column definitions.
Cell formatting and rendering
colDef(format = colFormat(prefix = "$", separators = TRUE)) formats cells as currency with comma separators. colDef(format = colFormat(datetime = TRUE, locales = "en-US")) formats datetime columns. colDef(html = TRUE) renders cell contents as HTML, enabling links, badges, and icons in cells.
colDef(cell = function(value, index, name) { ... }) provides full control over cell rendering. The function returns a string or htmltools tag. For conditional formatting: cell = function(value) { if (value > threshold) htmltools::tags$span(style = "color: red;", value) else value }.
colDef(style = function(value) { list(background = ifelse(value > 0, "lightgreen", "lightpink")) }) applies inline styles based on cell values. This achieves heatmap-style coloring without the complexity of custom rendering.
Column groups and spanning headers
columnGroups = list(colGroup(name = "Q1 Metrics", columns = c("jan", "feb", "mar"))) creates a spanning header over multiple columns. This mirrors Excel’s merged header cells and is useful for grouped data with multiple metrics per category.
colDef(aggregate = "sum") computes subtotals in grouped rows. Other aggregation options: "mean", "max", "min", "count", "unique", "frequency" (value counts), or a custom function.
Row groups and expandable rows
groupBy = "category" groups rows under expandable category headers. defaultExpanded = TRUE expands groups by default. Combined with aggregate, groups show summary values while rows show detail.
details = function(index) { htmltools::div(detailed_content) } creates expandable row details. Clicking a row expands it to show the returned content. This is the standard pattern for master-detail layouts — the main table shows summary data, expanding a row reveals the full record.
Theming and Shiny integration
reactableTheme(color = "#333", backgroundColor = "#fff", borderColor = "#ddd") sets table-wide colors. reactableTheme(headerStyle = list(background = "#f5f5f5")) styles just the headers. The glin/reactablefmtr package provides a high-level theming API with pre-built themes.
In Shiny: reactableOutput("table_id") in the UI; output$table_id <- renderReactable({ reactable(data) }) in the server. updateReactable("table_id", data = new_data) updates the table data without re-rendering the entire widget, preserving scroll position and expanded rows.
reactable::getReactableState("table_id", "selected") in Shiny returns the selected row indices. onClick = "select" enables row selection. This powers click-to-filter dashboards where selecting a table row updates other charts.
What makes reactable different
Most R table packages produce static output — you see the data as a formatted grid, but cannot interact with it. reactable produces HTML tables with built-in client-side sorting, filtering, and pagination powered by the React Table library. The entire table renders in the browser and responds to user interaction without any server involvement. This makes reactable tables appropriate for HTML reports, Shiny apps, and static websites that need exploratory data interaction.
The package builds tables declaratively. You describe the desired column behavior and appearance, and reactable handles the implementation. Columns can have custom renderers that produce arbitrary HTML — sparklines, progress bars, colored badges, or formatted numbers. This flexibility comes at the cost of complexity: a production reactable table with rich formatting requires more code than an equivalent gt or kableExtra table, but the result is interactive in ways those packages cannot match.
Grouping and aggregation
reactable supports groupBy columns that collapse rows into groups with expandable detail rows. The aggregate argument on a column definition specifies how values in grouped rows should be summarized — sum, mean, max, min, count, or a custom JavaScript function. Groups can be nested: grouping by region then by department creates a two-level expandable hierarchy.
When designing grouped tables, choose summary functions that are meaningful for each column type. Summing a rate column produces a nonsensical result; averaging it may also be wrong if group sizes differ. Numeric columns that represent counts should use sum; columns that represent rates or proportions should use a weighted mean or be left blank in grouped rows to avoid misleading summaries.
Pagination and performance
By default, reactable paginates large tables showing ten rows per page. The defaultPageSize argument controls this. For small datasets where showing everything at once is better than pagination, set defaultPageSize to the number of rows or use pagination = FALSE. Searching and sorting still work with pagination disabled.
For very large tables, client-side reactable tables can be slow because all data is embedded in the HTML. Tables with more than a few thousand rows benefit from server-side filtering and pagination, which requires Shiny for the data management piece. The reactable package integrates with Shiny through reactable’s server-side API, where R code handles filtering and pagination and sends only the current page’s data to the browser.