I'm relatively new to Shiny. I'm trying to get my head around using R6 classes to share objects across different modules (as suggested in the Enginering Shiny book). I made a simple app to upload the palmerpenguins::penguins
dataset as a .csv file and generate a plot.
I'm getting the following error when running the app:
Caused by error in `.data$bill_length_mm`:
! Column `bill_length_mm` not found in
`.data`.
I guess this is because the app is trying to plot the data before the dataset is actually uploaded. I thought I would need something like observeEvent()
in the plotServer
module but I'm still getting the error. Any idea what I'm doing wrong?
Here's the code:
library(shiny)
library(ggplot2)
DataHandler <- R6::R6Class(
"DataHandler",
private = list(
data = NULL
),
public = list(
read = function(file) {
private$data <- readr::read_csv(file$datapath)
},
plot = function() {
ggplot(private$data, aes(
.data$bill_length_mm,
.data$bill_depth_mm,
color = .data$species,
)) +
geom_point()
}
)
)
uploadUI <- function(id) {
ns <- NS(id)
tagList(
fileInput(
ns("file"),
label = "Choose a file:",
accept = "csv",
)
)
}
uploadServer <- function(id, r6) {
moduleServer(id, function(input, output, session) {
reactive({
req(input$file)
r6$read(input$file)
gargoyle::trigger("upload")
})
})
}
plotUI <- function(id) {
ns <- NS(id)
tagList(
plotOutput(ns("plot"))
)
}
plotServer <- function(id, r6) {
moduleServer(id, function(input, output, session) {
observeEvent(gargoyle::watch("upload"), {
output$plot <- renderPlot(r6$plot())
})
})
}
testApp <- function() {
ui <- fluidPage(
sidebarLayout(
sidebarPanel(
uploadUI("file")
),
mainPanel(
plotUI("plot")
)
)
)
server <- function(input, output, session) {
r6 <- DataHandler$new()
gargoyle::init("upload")
uploadServer("file", r6)
plotServer("plot", r6)
}
shinyApp(ui, server)
}
testApp()
Confusingly, init("upload")
actually triggers the flag, so your observeEvent
isn't stopping that from triggering as you intend. See my examples in Unintentional triggering caused by `init()` · Issue #23 · ColinFay/gargoyle · GitHub You can get around that by adding req()
inside your renderPlot
but in your current version there is nothing that you can wait for other than using req(gargoyle::watch("upload") > 0)
Is there a reason your data
object is private? If you make that public instead then you could use that to block the execution. Also the reactive
in uploadServer
isn't doing what you think it is - that will never actually read the data in. You need to use observeEvent(input$file,
to trigger the function when a file is uploaded.
A bit of aside to your actual question, but it is probably easier if you just use the R6 to store data and handle the functions separately in R/
. I just initiate the objects in an R6 as NULL
, e.g. disagapp/inst/shiny/common.R at main · simon-smart88/disagapp · GitHub
library(shiny)
library(ggplot2)
DataHandler <- R6::R6Class(
"DataHandler",
private = list(
data = NULL
),
public = list(
read = function(file) {
private$data <- readr::read_csv(file$datapath)
},
plot = function() {
print(head(private$data))
ggplot(private$data, aes(
.data$bill_length_mm,
.data$bill_depth_mm,
color = .data$species,
)) +
geom_point()
}
)
)
uploadUI <- function(id) {
ns <- NS(id)
tagList(
fileInput(
ns("file"),
label = "Choose a file:",
accept = "csv",
)
)
}
uploadServer <- function(id, r6) {
moduleServer(id, function(input, output, session) {
observeEvent(input$file, {
r6$read(input$file)
gargoyle::trigger("upload")
})
})
}
plotUI <- function(id) {
ns <- NS(id)
tagList(
plotOutput(ns("plot"))
)
}
plotServer <- function(id, r6) {
moduleServer(id, function(input, output, session) {
output$plot <- renderPlot({
gargoyle::watch("upload")
req(gargoyle::watch("upload") > 0)
r6$plot()
})
})
}
testApp <- function() {
ui <- fluidPage(
sidebarLayout(
sidebarPanel(
uploadUI("file")
),
mainPanel(
plotUI("plot")
)
)
)
server <- function(input, output, session) {
r6 <- DataHandler$new()
gargoyle::init("upload")
uploadServer("file", r6)
plotServer("plot", r6)
}
shinyApp(ui, server)
}
testApp()
1 Like
Thanks a lot for the explanation, links and fix!
It's usually not recommended to use public fields in OOP because anyone could then change the object stored in that field to whatever they like which can break the code (or in some cases retrieve sensitive data).
In my example, I could simply add a public method to get the data:
get_data = function() {
private$data
}
And then use req(r6$get_data())
.
In that case, I would simply use a new environment to store your objects. All the beauty of OOP is to have mutable data and methods that interact with those data. I wouldn't use OOP if not for that purpose.
No problem. Admittedly OOP is a bit of a mystery to me, but in a shiny app there's no way for the user to modify or retrieve the public values, unless the developer provides a way do so. That is very different to if you are using an R6 in a user-facing package. The public list is the method suggested by Colin Fay, e.g. https://stackoverflow.com/questions/67075905/how-to-include-r6-objects-to-share-data-across-modules-in-golem-shiny-app golemexamples/golemR6/R/R6.R at master · ColinFay/golemexamples · GitHub
Yes, that shouldn't be an issue if your app is well designed I guess. When collaborating on an app, that would also limit the risk of having a collaborator breaking the code by adding something like this:
r6$data <- NULL
It's usually considered good practice to implement your own setter method to control how the data should be modified.
R6 classes are made up of environments. Using R6 classes only to store data is equivalent to using new.env(parent = emptyenv())
(or rlang::new_environment()
). Nothing necessarily wrong with using R6 classes for that purpose but it's unecessary.