Reputation: 591
I'm trying to interface with a database within a shiny app in R. The gist of the issue is that the table in question has many rows and I'd prefer not to pull the entire table down to save on data transmission times. Instead, I'd like to iteratively pull data based on user input - specifically some combination of pagination controls with other shiny inputs to drill down the data (not shown).
My initial thought was to use reactable::getReactableState("table", "page")
and use this to construct a SQL query. Something like:
library(bslib)
library(shiny)
library(reactable)
library(data.table)
library(RSQLite)
shiny::shinyApp(
ui = bslib::page_navbar(
bslib::nav_panel(
title = "Reactable",
bslib::card(
reactable::reactableOutput("table")
)
)
),
server = function(input, output, session) {
# Connect to database
con <- dbConnect(RSQLite::SQLite(), ":memory:")
# Create table
DBI::dbExecute(
con,
"CREATE TABLE TestTable(TestID INTEGER PRIMARY KEY AUTOINCREMENT, TestValue INTEGER)"
)
# Write toy data
dt <- data.table(TestValue = runif(n = 50000, min = 1, max = 10) |> round())
dbWriteTable(con, "TestTable", dt, append = TRUE, row.names = FALSE)
# Function to fetch data for the current page
fetch_data <- function(page, page_size, total_rows) {
offset <- (page - 1) * page_size
query <- paste0(
"SELECT * FROM TestTable ORDER BY TestID LIMIT ", page_size, " OFFSET ", offset
)
y <- dbGetQuery(con, query) |> data.table::as.data.table()
# toy example of injecting NA rows
x <- 1:total_rows
x <- data.table::data.table(
TestID = x[!x %in% y$TestID]
)
y <- rbind(x, y, fill = TRUE)
y <- y[order(TestID)]
return(y)
}
# reactive values to hold data + pagination informatino
vals <- shiny::reactiveValues(
data = NULL,
page_size = 10,
page = 1,
total_rows = NULL
)
# Fetch the total number of rows
vals$total_rows <- dbGetQuery(con, "SELECT COUNT(*) AS Total FROM TestTable")$Total
# initial data pull
vals$data <- fetch_data(1, 10, dbGetQuery(con, "SELECT COUNT(*) AS Total FROM TestTable")$Total)
# when the page changes, fetch new data
shiny::observeEvent(
reactable::getReactableState("table", "page"),
{
vals$page <- reactable::getReactableState("table", "page")
vals$data <- fetch_data(vals$page, vals$page_size, vals$total_rows)
reactable::updateReactable("table", data = vals$data)
reactable::updateReactable("table", page = vals$page)
}
)
# render
output$table <- reactable::renderReactable({
reactable::reactable(
fetch_data(1, 10, dbGetQuery(con, "SELECT COUNT(*) AS Total FROM TestTable")$Total),
sortable = FALSE,
pagination = TRUE
)
})
# Drop table on stop
shiny::onStop(function() {
dbExecute(con, "DROP TABLE TestTable")
dbDisconnect(con)
})
}
)
As I see it, there are a couple key issues:
1. The only way I could figure out how to get the pagination UI to render without passing actual data was to inject NA
rows as shown in fetch_data
. This is not ideal, but only passing the reactable 10 rows (preferred) means that, while pagination = TRUE
, the pagination UI never shows up unless you supply more rows.
2. More importantly, the observeEvent()
on reactable::getReactableState("table", "page")
meant that:
observeEvent()
on reactable::getReactableState("table", "page")
once again with page = 1. Once that happens, it would pull the data corresponding the first page and re-render the table - overwriting the correct page (3) and correct data. If the reactable::renderReactable()
does not render a reactiveVal
and instead reactable::updateReactable(data = x)
is used, it creates an infinite loop.My solution was to basically mimic the pagination controls that reactable
creates with a uiOutput()
and renderUI()
which was a pain. I intend to post it as a solution, but it feels hacky and I'm hoping there's some way to implement something like this that is more straightforward.
Upvotes: 2
Views: 75
Reputation: 591
One way to approach the issue is to basically reconstruct the rt-pagination
div with shiny-enabled inputs. The below mimics what I would expect, though I'm sure it's got issues.
library(shiny)
library(bslib)
library(shinybusy)
library(reactable)
library(DBI)
library(data.table)
library(glue)
reactable_pagination <- function(inputId, x) {
# check for attr
needed_attr <- c("page", "total_rows", "page_size")
if (!all(needed_attr %in% names(x))) {
off <- !needed_attr %in% names(x)
off <- needed_attr[off]
cli::cli_abort(c("x" = "{.val {off}} is not in the list. {.val {needed_attr}} are required."))
}
# compute total pages
total_pages <- ceiling(x$total_rows / x$page_size)
# prev/next button disabled and value
prev_disabled <- ifelse(x$page == 1, " disabled ", " ")
prev_pg <- x$page - 1
next_disabled <- ifelse(x$page == total_pages, " disabled ", " ")
next_pg <- x$page + 1
# get current index
current_max <- x$page * x$page_size
current_row <- current_max - 9
# pages displayed
pg <- seq(x$page - 1, x$page + 1)
pg <- pg[pg <= total_pages & pg >= 1]
if (1 %in% pg) {
pg <- c(seq(1, 4), total_pages)
} else if (total_pages %in% pg) {
pg <- c(1, seq(total_pages - 3, total_pages))
} else {
pg <- c(1, pg, total_pages)
}
# determine if ellipsis needed
ell <- diff(pg)[c(1, 4)] > 1
# active page class
active <- ifelse(pg == x$page, " rt-page-button-current", "")
# construct buttons
btns <- glue::glue(
'<button type="button" class="rt-page-button{active}" aria-label="Page {pg}" onclick="Shiny.setInputValue(\'{inputId}\', {pg})">{format(pg, big.mark = ",")}</button>' # nolint
)
# inject ellipses
if (ell[1]) {
bt1 <- c(
btns[1],
shiny::span(class = "rt-page-ellipsis", role = "separator", "...") |> as.character()
)
} else {
bt1 <- btns[1]
}
if (ell[2]) {
bt5 <- c(
shiny::span(class = "rt-page-ellipsis", role = "separator", "...") |> as.character(),
btns[5]
)
} else {
bt5 <- btns[5]
}
btns <- c(bt1, btns[2:4], bt5) |> shiny::HTML()
# final div
ui <- shiny::div(
class = "rt-pagination",
shiny::div(
class = "rt-pagination info",
style = "border-top: 0px",
shiny::div(
class = "rt-page-info",
`area-live` = "polite",
glue::glue(
"{format(current_row, big.mark = ',')}-{format(current_max, big.mark = ',')} of {format(x$total_rows, big.mark = ',')} rows" # nolint
)
)
),
shiny::div(
class = "rt-pagination-nav",
shiny::HTML(
glue::glue(
'<button type="button" class="rt-prev-button rt-page-button"{prev_disabled}aria-label="Previous page" onclick="Shiny.setInputValue(\'{inputId}\', {prev_pg})">Previous</button>' # nolint
)
),
btns,
shiny::HTML(
glue::glue(
'<button type="button" class="rt-next-button rt-page-button"{next_disabled}aria-label="Next page" onclick="Shiny.setInputValue(\'{inputId}\', {next_pg})">Next</button>' # nolint
)
),
)
)
return(ui)
}
shiny::shinyApp(
ui = bslib::page_navbar(
bslib::nav_panel(
title = "Reactable",
bslib::card(
reactable::reactableOutput("prox"),
shiny::uiOutput("x")
)
)
),
server = function(input, output, session) {
# Connect to database
con <- dbConnect(RSQLite::SQLite(), ":memory:")
# Create table
DBI::dbExecute(
con,
"CREATE TABLE TestTable(TestID INTEGER PRIMARY KEY AUTOINCREMENT, TestValue INTEGER)"
)
# Write toy data
dt <- data.table(TestValue = runif(n = 50000, min = 1, max = 10) |> round())
dbWriteTable(con, "TestTable", dt, append = TRUE, row.names = FALSE)
# Initialize reactive values
sql <- shiny::reactiveValues(
data = NULL,
page = 1,
page_size = 10, # Rows per page
total_rows = NULL
)
# Fetch the total number of rows
sql$total_rows <- dbGetQuery(con, "SELECT COUNT(*) AS Total FROM TestTable")$Total
# Function to fetch data for the current page
fetch_data <- function(page, page_size, total_rows) {
offset <- (page - 1) * page_size
query <- paste0(
"SELECT * FROM TestTable ORDER BY TestID LIMIT ", page_size, " OFFSET ", offset
)
y <- dbGetQuery(con, query) |> data.table::as.data.table()
return(y)
}
# initial data pull
sql$data <- fetch_data(1, 10, dbGetQuery(con, "SELECT COUNT(*) AS Total FROM TestTable")$Total)
# Render reactable
output$prox <- reactable::renderReactable({
req(sql$data)
reactable::reactable(
sql$data,
sortable = FALSE,
pagination = FALSE
)
})
shiny::observeEvent(
input$react_page,
{
sql$page <- input$react_page
sql$data <- fetch_data(sql$page, sql$page_size, sql$total_rows)
}
)
output$x <- shiny::renderUI({
reactable_pagination("react_page", sql)
})
# Drop table on stop
shiny::onStop(function() {
dbExecute(con, "DROP TABLE TestTable")
dbDisconnect(con)
})
}
)
Upvotes: 1