Shiny Express: Advanced topics
This document was written and updated during the development of Shiny Express, and some parts of it may now be out of date.
Please see the official documentation for Shiny Express.
Shiny Express is built on top of “Core” Shiny – everything that you can do with Shiny Express you can also do in Shiny Core. The reverse isn’t exactly true – there are times where you’ll hit the limits of Shiny Express and need to use Shiny Core.
This document explains some of the technical details behind how Shiny Express works. Most Shiny Express users don’t need to read this document, but if you want to understand how it works under the hood, or want to do something more sophisticated with Shiny Express, you might find this document useful.
This page is still a work in progress, and some sections are not yet complete!
Special evaluation of Shiny Express app code
In a Shiny Core app, when you run shiny run app.py
from the command line, it effectively runs the Python code in app.py. In that code, you must create an object named app
, which is a Shiny.App
object. That object also conforms to the ASGI application specification, which means that it is a web application that can be run by an ASGI web server. For Shiny, we use uvicorn as the web server.
In a Shiny Express app, you do not explicitly create an object named app
; you actually do not create a Shiny.App
object anywhere. That is done automatically for you, when Shiny detects that it is a Shiny Express app.
How does Shiny detect when the app is an Express app? When you call shiny run app.py
, it parses the code and looks for an import
statement like these at the top level of the code. If it contains one of these, then Shiny knows that it’s an Express app, and evaluates the code in a special way, which we’ll talk about more below.
# Any of these imports indicates that it is a Shiny Express app
import shiny.express
from shiny import express
from shiny.express import input
Next: - special evaluation to capture side effects - create app object
Special evaluation of Express app code: UI as display
As mentioned above, the code of an Express app is evaluated in a special way. This is needed because the code needs to be evaluated in a way that is similar to Jupyter notebooks: in a Jupyter notebook, the results of most top-level statements are rendered in the notebook. Jupyter does this by evaluating each statement and calling IPython.display.display()
on the return value. In Shiny Express, we need to do something similar.
Before diving into Shiny Express, let’s talk about how Shiny Core works. In Shiny Core, you create UI by calling pure functions that return objects. Containership is expressed by passing children as arguments into the parent when the latter is being created. Eventually, the top-level parent is passed to Shiny via the App
object (or returned from a @render.ui
).
# Shiny Core
= ui.page_fixed(
app_ui
widget1(),
widget2(),
widget3(),
)
= App(app_ui, ...) app
In Shiny Express, top-level statements are evaluated in order, and any non-None
expression has something like Jupyter’s display()
called on it. This is intended to feel similar to a code chunk in .rmd or .qmd, or like a Jupyter notebook (with InteractiveShell.ast_node_interactivity="all"
).
# Shiny Express
# Displayed
widget1() = widget2() # Not displayed
w2 # Now it's displayed
w2 # Displayed widget3()
Shiny Express does not actually call IPython.display.display()
on each statement. Instead, it collects the result of each statement.
There’s an inherent tension in these decisions. What seems to be the case is that from a superficial level, imperative UI feels more intuitive than functional UI–that is, calling button()
should immediately “output” a button at the moment it’s called, rather than returning some kind of value. And indeed, there are UI frameworks that take this approach, including Streamlit.
But imperative UI starts to run into trouble very quickly, because programming against side-effects is harder than programming against values. For example, consider a function that takes a title as an argument, which could either be a string or an HTML element. How would you pass an HTML element if the very act of constructing an <h1>
causes it to be emitted as output? Or think about a function that takes a while to generate some UI; if UI is just returned objects, you can trivially cache the results in any number of naturally Pythonic ways, but if it’s side effects, you have to figure out some way of intercepting those side effects, and cache them using some custom mechanism.
The display()
approach of Jupyter/Quarto/Express serves as a compromise between Shiny Core’s functional approach and Streamlit’s imperative approach. The code looks very similar to imperative code, but we’re able to re-use most of our UI components even though they use the pure functional style. (Existing container components cannot be used in Express, as explained in the next section.)
One special challenge is dealing with expressions that are not at the top-level; that is, in functions. If you define a function in a Quarto code chunk or Jupyter notebook cell, its expressions will not be displayed/printed. The same is true in Shiny Express. However, it’s clearly desirable to be able to write functions that express UI, and it’d be great to do it without having to explicitly call sys.displayhook()
on every piece of UI. The @expressify
decorator does this by transforming a function’s body to behave like the top level (and see also @render.express
, which is sort of like @render.ui
plus @expressify
combined).
Express advantages:
- Same/similar paradigm as Quarto chunk or Jupyter notebook cell.
- Fewer commas/parentheses.
Core advantages:
- Easier refactoring of complicated UI (extracting into variables, parameterized functions, caching, etc.).
- Can write apps in Python modules/packages.
Containers as context managers
The prior two differences make container components a special challenge for Shiny Express. Shiny Core’s UI components are passed as function arguments, but Shiny Express’s render functions make this approach untenable; Python won’t let you declare a decorated function and pass it as a function argument at the same time.
As a result, you can’t use Shiny Core’s container UI functions with Shiny Express. Instead, we’ve created a Shiny Express port for each of our container functions.
Core syntax:
# Shiny Core
= ui.page_fixed(
app_ui
ui.card(
ui.input_slider(),"plot1"),
ui.output_plot(
)
)
def server(input, output, session):
@render.plot
def plot1():
...
= App(app_ui, server) app
Express syntax (notice that we don’t need to call page_fixed()
because it is the default page type for Express):
# Shiny Express
from shiny.express import layout
with layout.card():
ui.input_slider()
@render.plot
def plot1():
...
These new shiny.express.layout
container functions do not return objects, but rather, Python context managers that are side-effecty (they intercept sys.displayhook
while inside the with
block, and display()
themselves upon exit).
Express advantages:
- Makes it possible to put render functions inside UI containers.
- Fewer commas/parentheses.
Core advantages:
- As before, easier refactoring of complicated UI (extracting into variables, parameterized functions, caching, etc.).
- Container function signatures make it easier to see what arguments are allowed/required.
- No difference between child and container UI functions, in terms of: how you call them, how you handle their results, and how you write your own.
render
functions automatically create outputs
In the Core version of the previous example app, the server function contains a @render.plot; def plot1()
, and the UI contains a corresponding output, ui.output_plot("plot1")
. The output component is how Shiny Core knows where to put the plot on the page.
# Shiny Core
= ui.page_fixed(
app_ui
ui.card(
ui.input_slider(),"plot1"),
ui.output_plot(
)
)
def server(input, output, session):
@render.plot
def plot1():
...
= App(app_ui, server) app
In an Express app, you don’t need to explicitly call ui.output_plot("plot1")
. Instead, when you call @render.plot; def plot1()
, it automatically puts the corresponding output at that location:
# Shiny Express
from shiny.express import layout
with layout.card():
ui.input_slider()
@render.plot
def plot1():
...
Preventing display of objects with ui.hold()
If for some reason, you want to create an object but not render it into the page at that location, one way to do it is to simply assign it to a variable:
= widget() x
Just like in a Jupyter notebook, assigning the value to a variable prevents it from being displayed in the Shiny app. You can later put x
on a line by itself to display it there.
However, there are cases where you can’t do this with Python. If you have a @render.plot
, but don’t want it to put an output right there, you can’t assign it to a variable with x = ...
. This simply is not valid Python code:
# NOT valid code
= @render.plot
x def _():
...
For cases like this, you can use with ui.hold()
:
with ui.hold():
@render.plot
def plot1():
...
You could also put more render
functions in that same code block if you wanted.
You would have to add the corresponding output explicitly somewhere in your app:
"plot1", click=True) ui.output_plot(
This can be useful when you want to add an output_plot
with options that aren’t available in the render.plot()
function. (Although note that in a future version of Shiny, we plan to make all of those options, like click
, available in the render.plot()
function.)
You can also use with ui.hold() as x
, and then place x
later in the page.
# Create a card here...
with ui.hold() as hello_card:
with ui.card():
with ui.span():
"This is a"
" card", style="color: red;")
ui.span(
"Some content")
ui.h3(
# and display the card here
hello_card
@render.text()
for plain text, @render.code()
for code
When @render.text()
is used, it defaults to displaying as plain text, and when @render.code()
is used, it defaults to displaying in a monospaced font in a code block.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 300
from shiny.express import render, ui
@render.text
def txt():
return "This is @render.text"
ui.br()
@render.code
def code():
return "This is @render.code"
Using Express syntax in functions with @expressify
If you want to write a function using Express syntax (as opposed to Core syntax), you can use @expressify
. In the example below, you can compare the syntax to the version that’s written with Shiny Core syntax.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 300
from shiny.express import expressify, ui
# The Shiny Express syntax version
@expressify
def card(i: int):
with ui.card():
with ui.span():
"This is a card with @expressify: "
ui.span(str(i), style="color: red;")
card(1)
card(2)
# The Shiny Core syntax version, with nested function calls
def card_core(i: int):
from shiny import ui as sui
return sui.card(
sui.span(
"This is a card with Shiny Core: ",
sui.span(str(i), style="color: blue;"),
)
)
card_core(1)
card_core(2)