sonshine
sonshine

Reputation: 591

How to iteratively fetch data in reactable in R?

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:

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

Answers (1)

sonshine
sonshine

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

Related Questions