-
Notifications
You must be signed in to change notification settings - Fork 0
/
scaling-functions.Rmd
303 lines (225 loc) · 13.1 KB
/
scaling-functions.Rmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# Functions {#scaling-functions}
```{r, include = FALSE}
source("common.R")
```
As your app gets bigger, it will get harder and harder to hold all the pieces in your head, making it harder and harder to understand.
In turn, this makes it harder to add new features, and harder to find a solution when something goes wrong (i.e. it's harder to debug).
If you don't take deliberate steps, the development pace of your app will slow, and it will become less and less enjoyable to work on.
In this chapter, you'll learn how writing functions can help.
This tends to have slightly different flavours for UI and server components:
- In the UI, you have components that are repeated in multiple places with minor variations.
Pulling out repeated code into a function reduces duplication (making it easier to update many controls from one place), and can be combined with functional programming techniques to generate many controls at once.
- In the server, complex reactives are hard to debug because you need to be in the midst of the app.
Pulling out a reactive into a separate function, even if that function is only called in one place, makes it substantially easier to debug, because you can experiment with computation independent of reactivity.
Functions have another important role in Shiny apps: they allow you to spread out your app code across multiple files.
While you certainly can have one giant `app.R` file, it's much easier to manage when spread across multiple files.
I assume that you're already familiar with the basics of functions[^scaling-functions-1].
The goal of this chapter is to activate your existing skills, showing you some specific cases where using functions can substantially improve the clarity of your app.
Once you've mastered the ideas in this chapter, the next step is to learn how to write code that requires coordination across the UI and server.
That requires **modules**, which you'll learn about in Chapter \@ref(scaling-modules).
[^scaling-functions-1]: If you're not, and you'd like to learn the basics, you might try reading the [Functions chapter](https://r4ds.had.co.nz/functions.html) of *R for Data Science*.
```{r setup}
library(shiny)
```
## File organisation
Before we go on to talk about exactly how you might use functions in your app, I want to start with one immediate benefit: functions can live outside of `app.R`.
There are two places you might put them depending on how big they are:
- I recommend putting large functions (and any smaller helper functions that they need) into their own `R/{function-name}.R` file.
- You might want to collect smaller, simpler, functions into one place.
I often use `R/utils.R` for this, but if they're primarily used in your ui you might use `R/ui.R`.
If you've made an R package before, you might notice that Shiny uses the same convention for storing files containing functions.
And indeed, if you're making a complicated app, particularly if there are multiple authors, there are substantial advantages to making a full fledged package.
If you want to do this, I recommend reading the ["Engineering Shiny"](https://engineering-shiny.org) book and using the accompanying [golem](https://thinkr-open.github.io/golem/) package.
We'll touch on packages again when we talk more about testing.
## UI functions
Functions are a powerful tool to reduce duplication in your UI code.
Let's start with a concrete example of some duplicated code.
Imagine that you're creating a bunch of sliders that each need to range from 0 to 1, starting at 0.5, with a 0.1 step.
You *could* do a bunch of copy and paste to generate all the sliders:
```{r}
ui <- fluidRow(
sliderInput("alpha", "alpha", min = 0, max = 1, value = 0.5, step = 0.1),
sliderInput("beta", "beta", min = 0, max = 1, value = 0.5, step = 0.1),
sliderInput("gamma", "gamma", min = 0, max = 1, value = 0.5, step = 0.1),
sliderInput("delta", "delta", min = 0, max = 1, value = 0.5, step = 0.1)
)
```
But I think it's worthwhile to recognise the repeated pattern and extract out a function.
That makes the UI code substantially simpler:
```{r}
sliderInput01 <- function(id) {
sliderInput(id, label = id, min = 0, max = 1, value = 0.5, step = 0.1)
}
ui <- fluidRow(
sliderInput01("alpha"),
sliderInput01("beta"),
sliderInput01("gamma"),
sliderInput01("delta")
)
```
Here a function helps in two ways:
- We can give the function a evocative name, making it easier to understand what's going on when we re-read the code in the future.
- If we need to change the behaviour, we only need to do it in one place.
For example, if we decided that we needed a finer resolution for the steps, we only need to write `step = 0.01` in one place, not four.
### Other applications
Functions can be useful in many other places.
Here are a few ideas to get your creative juices flowing:
- If you're using a customised `dateInput()` for your country, pull it out into one place so that you can use consistent arguments.
For example, imagine you wanted a date control for Americans to use to select weekdays:
```{r}
usWeekDateInput <- function(inputId, ...) {
dateInput(inputId, ..., format = "dd M, yy", daysofweekdisabled = c(0, 6))
}
```
Note the use of `...`; it means that you can still pass along any other arguments to `dateInput()`.
- Or maybe you want a radio button that makes it easier to provide icons:
```{r}
iconRadioButtons <- function(inputId, label, choices, selected = NULL) {
names <- lapply(choices, icon)
values <- if (is.null(names(choices))) names(choices) else choices
radioButtons(inputId,
label = label,
choiceNames = names, choiceValues = values, selected = selected
)
}
```
- Or if there are multiple selections you reuse in multiple places:
```{r}
stateSelectInput <- function(inputId, ...) {
selectInput(inputId, ..., choices = state.name)
}
```
If you're developing a lot of Shiny apps within your organisation, you can help improve cross-app consistency by putting functions like this in a shared package.
### Functional programming
Returning back to our motivating example, you could reduce the code still further if you're comfortable with functional programming.
```{r}
library(purrr)
vars <- c("alpha", "beta", "gamma", "delta")
sliders <- map(vars, sliderInput01)
ui <- fluidRow(sliders)
```
There are two big ideas here:
- `map()` calls `sliderInput01()` once for each string stored in `vars`.
It returns a list of sliders.
- When you pass a list into `fluidRow()` (or any html container), it automatically unpacks the list so that the elements become the children of the container.
If you would like to learn more about `map()` (or its base equivalent, `lapply()`), you might enjoy the [Functionals chapter](https://adv-r.hadley.nz/functionals.html) of *Advanced R*.
### UI as data
It's possible to generalise this idea further if the controls have more than one varying input.
First, we create an inline data frame that defines the parameters of each control, using `tibble::tribble()`.
We're turning UI structure into an explicit data structure.
```{r}
vars <- tibble::tribble(
~ id, ~ min, ~ max,
"alpha", 0, 1,
"beta", 0, 10,
"gamma", -1, 1,
"delta", 0, 1,
)
```
Then we create a function where the argument names match the column names:
```{r}
mySliderInput <- function(id, label = id, min = 0, max = 1) {
sliderInput(id, label, min = min, max = max, value = 0.5, step = 0.1)
}
```
Then finally we use `purrr::pmap()` to call `mySliderInput()` once for each row of `vars`:
```{r}
sliders <- pmap(vars, mySliderInput)
```
Don't worry if this code looks like gibberish to you: you can continue to use copy and paste.
But in the long-run, I'd recommend learning more about functional programming, because it gives you such a wonderful ability to concisely express otherwise long-winded concepts.
You can see other examples See Section \@ref(programming-ui) for more examples of using these techniques to generate dynamic UI in response to user actions.
## Server functions
Whenever you have a long reactive (say \>10 lines) you should consider pulling it out into a separate function that does not use any reactivity.
This has two advantages:
- It is much easier to debug and test your code if you can partition it so that reactivity lives inside of `server()`, and complex computation lives in your functions.
- When looking at a reactive expression or output, there's no way to easily tell exactly what values it depends on, except by carefully reading the code block.
A function definition, however, tells you exactly what the inputs are.
The key benefits of a function in the UI tend to be around reducing duplication.
The key benefits of functions in a server tend to be around isolation and testing.
### Reading uploaded data {#function-upload}
Take this server from Section \@ref(uploading-data).
It contains a moderately complex `reactive()`:
```{r}
server <- function(input, output, session) {
data <- reactive({
req(input$file)
ext <- tools::file_ext(input$file$name)
switch(ext,
csv = vroom::vroom(input$file$datapath, delim = ","),
tsv = vroom::vroom(input$file$datapath, delim = "\t"),
validate("Invalid file; Please upload a .csv or .tsv file")
)
})
output$head <- renderTable({
head(data(), input$n)
})
}
```
If this was a real app, I'd seriously consider extracting out a function specifically for reading uploaded files:
```{r}
load_file <- function(name, path) {
ext <- tools::file_ext(name)
switch(ext,
csv = vroom::vroom(path, delim = ","),
tsv = vroom::vroom(path, delim = "\t"),
validate("Invalid file; Please upload a .csv or .tsv file")
)
}
```
When extracting out such helpers, avoid taking reactives as input or returning outputs.
Instead, pass values into arguments and assume the caller will turn the result into a reactive if needed.
This isn't a hard and fast rule; sometimes it will make sense for your functions to input or output reactives.
But generally, I think it's better to keep the reactive and non-reactive parts of your app as separate as possible.
In this case, I'm still using `validate()`; that works because outside of Shiny `validate()` works similarly to `stop()`.
But I keep the `req()` in the server, because it shouldn't be the responsibility of the file parsing code to know when it's run.
Since this is now an independent function, it could live in its own file (`R/load_file.R`, say), keeping the `server()` svelte.
This helps keep the server function focused on the big picture of reactivity, rather than the smaller details underlying each component.
```{r}
server <- function(input, output, session) {
data <- reactive({
req(input$file)
load_file(input$file$name, input$file$datapath)
})
output$head <- renderTable({
head(data(), input$n)
})
}
```
The other big advantage is that you can play with `load_file()` at the console, outside of your Shiny app.
If you move towards formal testing of your app (see Chapter \@ref(scaling-testing)), this also makes that code easier to test.
### Internal functions
Most of the time you'll want to make the function completely independent of the server function so that you can put it in a separate file.
However, if the function needs to use `input`, `output`, or `session` it may make sense for the function to live inside the server function:
```{r}
server <- function(input, output, session) {
switch_page <- function(i) {
updateTabsetPanel(input = "wizard", selected = paste0("page_", i))
}
observeEvent(input$page_12, switch_page(2))
observeEvent(input$page_21, switch_page(1))
observeEvent(input$page_23, switch_page(3))
observeEvent(input$page_32, switch_page(2))
}
```
This doesn't make testing or debugging any easier, but it does reduce duplicated code in.
We could of course add `session` to the arguments of the function:
```{r}
switch_page <- function(i) {
updateTabsetPanel(input = "wizard", selected = paste0("page_", i))
}
server <- function(input, output, session) {
observeEvent(input$page_12, switch_page(2))
observeEvent(input$page_21, switch_page(1))
observeEvent(input$page_23, switch_page(3))
observeEvent(input$page_32, switch_page(2))
}
```
But this feels weird as the function is still fundamentally coupled to this app because it only affects a control named "wizard" with a very specific set of tabs.
## Summary
As your apps get bigger, extracting non-reactive functions out of the flow of the app will make your life substantially easier.
Functions allow you to reactive and non-reactive code and spread your code out over multiple files.
This often makes it much easier to see the big picture shape of your app, and by moving complex logic out of the app into regular R code it makes it much easier to experiment, iterate, and test.
When you start extracting out function, it's likely to feel a bit slow and frustrating, but over time you'll get faster and faster, and soon it will become a key tool in your toolbox.
This functions in this chapter have one important drawback --- they can generate only UI or server components, not both.
In the next chapter, you'll learn how to create Shiny modules, which coordinate UI and server code into a single object.