library(shiny)
library(bs4Dash)
<- dashboardPage(
ui header = dashboardHeader(),
sidebar = dashboardSidebar(),
body = dashboardBody(
sortable(
box(
textAreaInput('box_1', label = NULL, value = 'Box 1')
),box(
textAreaInput('box_2', label = NULL, value = 'Box 2')
),box(
textAreaInput('box_3', label = NULL, value = 'Box 3')
),actionButton('collect_btn', label = 'Collect content')
)
)
)
<- function(input, output, session) {
server
}
shinyApp(ui, server)
How to enhance your Shiny apps with JavaScript (JS) without knowing much JS
I do not know much about JavaScript (JS). But what I do know is that JS can seriously spice up your Shiny apps. After all, JS is one of the three key programming languages of the internet.
So if there’s something you cannot do with {shiny}
(or extensions), then you can make it work directly with JS. And the good news is that the first steps are not that hard.
I know that because I can barely use JS but. But every now and then I need it to enhance my apps. In this blog post, I show you how I make that work. Despite my lack of an in-depth understanding of JS.
The easiest way to do that is through an example. Here’s a minimal dashboard that we’re going to enhance. It’s built with {shiny}
and {bs4Dash}
.
As you can see, this app consists of three boxes that each have a text input. Due to sortable()
we can rearrange these boxes interactively. This means we could drag-and-drop Box 3 above Box 1. You can try that yourself in the live app.
Did you notice the button at the bottom of the page? It is designed to get the texts from the boxes in the current order (after having rearranged them interactively). Thus, we can’t simply call the inputs input$box_1
, input$box_2
and input$box_3
like we normally would. This would only work if the boxes are in their default order. This is where JS will help us.
Play around with your web browser’s console
Now that we have an app, it’s time to explore it’s web code. This means we run this app, open it in our web browser and then open the developer mode. This can be done by right-clicking anywhere and then clicking “Inspect”.
This let’s us inspect all elements of our app. Inspecting one of the boxes shows us that all boxes use the <textarea>
tag. Thus, we can find our texts by using JS to find all elements with that specific tag.
A quick web-search yields that a specific HTML tag can be found with
document.getElementsByTagName("<insert tag name>");
Now the question becomes where to put this code. Notice that the developer mode in your web browser has a “Console” tab (ctrl + shift + K in Firefox). That sounds like something that knows how to handle JS code. And it can. Sending
document.getElementsByTagName("textarea");
to your browser’s console will return what seems to be called an HTML collection.
The output is quite weird. At least for someone like me who is not used to JS. The collection has length three - that’s what it says - but it displays six different elements.
Turns out, you have two ways to extract a specific element (name and index) which are both displayed. That’s not so different from R. In fact, the syntax to access elements is pretty analogous to R. Here’s how it looks in JS. Watch out for zero-indexing though.
document.getElementsByTagName("textarea")[0];
document.getElementsByTagName("textarea")['box_1'];
Notice that the HTML collection that we get is always in the correct order. Just try shuffling the boxes and then reexecute the JS command. It will show you the boxes in the order that is currently displayed in the app. That’s exactly what we need.
Variable declaration and looping
Alright, we know how to extract an HTML collection. Time to save it into a variable so that we can reuse it. You can declare a new variable with var
as in
var text_areas = document.getElementsByTagName("textarea");
If you type this into your console, you will notice that it returns undefined
. Don’t be alarmed by this. This just means that the last command did not return anything. It does not mean that it didn’t do anything. You can now type text_areas
into your console and it will display your precious HTML collection.
Loop with for()
Next, we need to extract the actual texts from the HTML collection. We can get the texts from one HTML element via its value
property which we access using the .
notation. This might look like so
0].value text_areas[
Executing this will show you whatever text you’ve written in the first box. Of course, we want to extract all texts. Thus, we need to access all value
properties of all elements in our HTML collection. We can do that with a for-loop. Here’s how a similar task could look in R.
<- c()
new_array
for (i in seq_along(old_array)) {
<- old_array[i]
new_array[i] }
Turns out, JS can use the exact same syntax to create for-loops. We just need to figure out how to replicate seq_along()
in JS. This works with a somewhat strange syntax. We can create an array (think vector) [0, 1, 2]
with
...Array(text_areas.length).keys()] [
Of course, when we would use more text boxes, we would have more entries in our array. Also, an empty array is created with []
. Thus, we know that we will have to write something like
var texts = [];
for (i in [...Array(text_areas.length).keys()]) {
// instructions in here
; }
Finally, we can append an element to an array using an array’s internal push()
function. Same as with the value
property, a .
lets us access an object’s internal functions.
var texts = [];
for (i in [...Array(text_areas.length).keys()]) {
.push(text_areas[i].value);
texts; }
After this code finished you will see that the output is 3
. Nevermind that. It’s just the output from the last push()
call (which returns the length of the new array after appending). Calling texts
will show you that the texts are saved in our array.
Loop with functional programming
Before we move on, let me show you another way to extract the texts from all boxes without a for-loop. If you like, map()
from {purrr}
, then you’re going to love this. In R, we could use map()
to iteratively do any task. For example, we could compute the cumulative sum of a vector like so.
<- runif(10) |> round()
random_vector
random_vector## [1] 1 0 0 0 0 0 1 1 0 1
::map_dbl( # Use map_dbl() to enforce numeric vector output
purrrseq_along(random_vector),
function(x) sum(random_vector[1:x])
)## [1] 1 1 1 1 1 1 2 3 3 4
The same approach can be used in JS. This means that we
- create a vector that contains indices 0, 1, 2,…
- declare a function that does the text extraction
- wrap everything in some sort of map function
And the solutions for these three steps in JS are as follows:
- Step 1 uses the weird
[...Array(<number here>).keys()]
syntax again. - Step 2 uses
x => <some fct based on x>
(think of this as the~
short-hand that you know frompurrr::map()
) - Step 3 is based on the internal
map()
function of every array.
So, putting all of this together will give us
...Array(text_areas.length).keys()].map(x => text_areas[x].value) [
and using this in your browser’s console will give you exactly the same output that we’ve seen before.
Create a function for Shiny applications
Our JS code is almost complete now. But we have to wrap the code into a function. Once again, the JS syntax is quite similar to what you know from R.
.collect_content = function() {
shinyjsvar text_areas = document.getElementsByTagName('textarea');
var texts = [...Array(text_areas.length).keys()].map(x => text_areas[x].value);
.setInputValue('texts', texts);
Shiny; }
Notice that there is an additional last line. This one is important. It will take the texts
JS variable and make the content available in R under the name input$texts
.
Also, I’ve named the function shinyjs.collect_content()
because I want to use it in conjunction with the shinyjs
package. But it’s also possible to include JS code without using {shinyjs}
. I’ll show you that too.
Observe the texts
input
Our JS function will make the content of the text boxes available under the name input$texts
. So let us tie an observer to that variable in our server()
function. For example, we can print the content to the console when it becomes available in R. This way, we know that our function worked. Here’s our new server()
function:
<- function(input, output, session) {
server <- reactive({input$texts})
collected_texts
observe({
print(collected_texts())
|> bindEvent(collected_texts())
}) }
Include JS code via text variables
The easiest way to use a JS function with {shinyjs}
is to follow these three steps:
Load
{shinyjs}
and include your JS function as a text variable in your app’s R script:library(shinyjs) <- "shinyjs.collect_content = function() { jsCode var text_areas = document.getElementsByTagName('textarea'); var texts = [...Array(text_areas.length).keys()].map(x => text_areas[x].value); Shiny.setInputValue('texts', texts); };"
Enable
{shinyjs}
by placinguseShinyjs()
somewhere in your UI.Register your JS function with
{shinyjs}
by placingextendShinyjs(text = jsCode, functions = 'collect_content')
somewhere in your UI.
Notice that the last step will only work, if the name of your JS function uses shinyjs.
as a prefix. Also, the functions
argument is used to describe the names that your JS function will have within R. I suggest that you use the same name but without the shinyjs.
prefix.
In case you need a quick recap, here’s the full code of our current app:
Shiny App
library(shiny)
library(bs4Dash)
library(shinyjs)
<- "shinyjs.collect_content = function() {
jsCode var text_areas = document.getElementsByTagName('textarea');
var texts = [...Array(text_areas.length).keys()].map(x => text_areas[x].value);
Shiny.setInputValue('texts', texts)
}
"
<- dashboardPage(
ui header = dashboardHeader(),
sidebar = dashboardSidebar(),
body = dashboardBody(
####### NEW JS PART
useShinyjs(),
extendShinyjs(text = jsCode, functions = 'collect_content'),
#######
sortable(
box(
textAreaInput('box_1', label = NULL, value = 'Box 1')
),box(
textAreaInput('box_2', label = NULL, value = 'Box 2')
),box(
textAreaInput('box_3', label = NULL, value = 'Box 3')
),actionButton('collect_btn', label = 'Collect content')
)
)
)
<- function(input, output, session) {
server <- reactive({input$texts})
collected_texts
observe({
print(collected_texts())
|> bindEvent(collected_texts())
})
}
shinyApp(ui, server)
Finally, the last ingredient is to tie our JS function to a button click. Luckily, that’s really simple. You can instruct your app to run a JS function that has been registered with {shinyjs}
by calling js$fct_name()
.
Thus, let us execute collect_content()
whenever our button is clicked. This works by extending our server
function a little bit.
<- function(input, output, session) {
server <- reactive({input$texts})
collected_texts
observe({
print(collected_texts())
|> bindEvent(collected_texts())
})
observe({
$collect_content()
js|> bindEvent(input$collect_btn)
}) }
Everything else remains unchanged. Unfold if you want to see the full code.
Shiny App
library(shiny)
library(bs4Dash)
library(shinyjs)
<- "shinyjs.collect_content = function() {
jsCode var text_areas = document.getElementsByTagName('textarea');
var texts = [...Array(text_areas.length).keys()].map(x => text_areas[x].value);
Shiny.setInputValue('texts', texts)
}
"
<- dashboardPage(
ui header = dashboardHeader(),
sidebar = dashboardSidebar(),
body = dashboardBody(
####### NEW JS PART
useShinyjs(),
extendShinyjs(text = jsCode, functions = 'collect_content'),
#######
sortable(
box(
textAreaInput('box_1', label = NULL, value = 'Box 1')
),box(
textAreaInput('box_2', label = NULL, value = 'Box 2')
),box(
textAreaInput('box_3', label = NULL, value = 'Box 3')
),actionButton('collect_btn', label = 'Collect content')
)
)
)
<- function(input, output, session) {
server <- reactive({input$texts})
collected_texts
observe({
print(collected_texts())
|> bindEvent(collected_texts())
})
observe({
$collect_content()
js|> bindEvent(input$collect_btn)
})
}
shinyApp(ui, server)
This finshes our app. Check it out. I can now rearrange the boxes and when I click the button, the console will display the texts in the correct order.
Reading JS code from a file
Clearly, adding JS code by text variables clutters your app quite fast. So instead, try moving your JS code to a separate file. For example, I’ve saved the exact same code from before in a file collect_content.js
(just create a text file and rename it so that it has .js
file extension).
Now, I can just point the script
argument of extendShinyjs()
to collect_content.js
. But here’s where you have to watch out: Your .js
-files need to be stored in a directory called www
. I know, this confused me as well. We write
extendShinyjs(script = 'collect_content.js', ...)
but actually mean
extendShinyjs(script = 'www/collect_content.js', ...)
Here’s how that looks in our app.
Full Shiny app
library(shiny)
library(bs4Dash)
library(shinyjs)
<- dashboardPage(
ui header = dashboardHeader(),
sidebar = dashboardSidebar(),
body = dashboardBody(
useShinyjs(),
extendShinyjs(
script = 'collect_content.js', ## Saved in www/ directory
functions = 'collect_content'
), sortable(
box(
textAreaInput('box_1', label = NULL, value = 'Box 1')
),box(
textAreaInput('box_2', label = NULL, value = 'Box 2')
),box(
textAreaInput('box_3', label = NULL, value = 'Box 3')
),
actionButton('collect_btn', label = 'Collect content')
)
)
)
<- function(input, output, session) {
server <- reactive({input$texts})
collected_texts
observe({
print(collected_texts())
|> bindEvent(collected_texts())
})
observe({
$collect_content()
js|> bindEvent(input$collect_btn)
})
}
shinyApp(ui, server)
This way, you can also define multiple JS functions in one file and load them all at once. In that case, you will have to use a vector for the functions
argument of extendShinyjs()
.
Incorporate JS without {shinyjs}
Alright, we already accomplished what we said we would do. But I promised you a way to do the same even without {shinyjs}
. Here’s what you need for that.
Set the
onclick
attribute of your button. This is HTML speak for “Define what JS code is executed when the button is clicked”. Here, we would just passonclick = "collect_content();"
to our functionactionButton()
in the UI.Sneak the JS code for
collect_content()
into your app. This works by placingtags$script(<code as HTML here>)
into the UI. Note that the function’s name will not contain theshinyjs.
prefix anymore. This prefix works only with{shinyjs}
.
Here’s what the new code could look like:
Shiny App
library(shiny)
library(bs4Dash)
### WRAP TEXT IN HTML() CALL FOR PROPER PARSING
<- HTML("collect_content = function() {
jsCode var text_areas = document.getElementsByTagName('textarea');
var texts = [...Array(text_areas.length).keys()].map(x => text_areas[x].value);
Shiny.setInputValue('texts', texts)
}
")
<- dashboardPage(
ui header = dashboardHeader(),
sidebar = dashboardSidebar(),
body = dashboardBody(
####### REPLACE SHINYJS STUFF WITH tags$script()
$script(jsCode),
tags#######
sortable(
box(
textAreaInput('box_1', label = NULL, value = 'Box 1')
),box(
textAreaInput('box_2', label = NULL, value = 'Box 2')
),box(
textAreaInput('box_3', label = NULL, value = 'Box 3')
),actionButton(
'collect_btn',
label = 'Collect content',
onclick = 'collect_content();'
)
)
)
)
<- function(input, output, session) {
server <- reactive({input$texts})
collected_texts
observe({
print(collected_texts())
|> bindEvent(collected_texts())
})
}
shinyApp(ui, server)
There’s a couple of things about this app worth noting:
The JS code needs to be wrapped in
HTML()
. Of course, we can also save the code in a file and read it likeread_lines('file.js') |> HTML()
.Notice that we do not need an observer for the the button click in the
server()
function anymore. This part is now handled manually by the button’s integrated JS code.
Conclusion
Alright, this concludes our little JavaScript tour. As always, programming contains a lot of trying out new things. And with your web browser’s console you’ve got a tool to do just that.
Clearly, you’re not a JS master after this. But you’ve got your foot in the door. All of the JS functions that you’ve seen in this blog post were found through a quick web search and a bit of trial-and-error on the console.
Do not underestimate how much you can get done with this. After all, you don’t need to write a full app with JS. You only need to sprinkle in small parts that are not readily available with {shiny}
.
I hope that you’ve enjoyed this blog post. If you have any more questions, feel free to reach out to me in the comments. Or you can contact me on Twitter or Mastodon. See you next time!