Streamlit Components are out ! This opens a lot of new possibilities as it makes it easy to integrate any interactive Javascript library into Streamlit, and send data back and forth between a Python script and a React component.
Let's build a quick example around it to help quickstart an answer to a forum question about Plotly cross-interactive filtering : can we build a scatterplot where selected points are sent back into Python ? We will solve this by creating a Plotly.js component with lasso selection and communicate selected points to Python.
While this tutorial is accessible to anyone with no JS/React experience, I do skip on some important frontend/React concepts. I have a longer tutorial here that addresses them but don't hesitate to check the React website if you want to go deeper.
Prerequisite : How does plotly work ?
A lot of Data Scientists use plotly.py to build interactive charts. plotly.py is actually a Python wrapper for plotly.js. Plotly figures are represented by a JSON specification which the Python client generates for you that plotly.js can consume to render a graph.
If you know Altair from the Vega ecosystem, Altair works as a Python wrapper for Vega-lite in the exact same way plotly.py is a Python wrapper for plotly.js.
You can retrieve the JSON specification from a Figure in Python :
import plotly.express as px
fig = px.line(x=["a","b","c"], y=[1,3,2], title="sample figure")
fig.to_json()
# renders a somewhat big JSON spec
# {"data":[{"hovertemplate":"x=%{x}<br>y=%{y}<extra></extra>","legendgroup":"","line":{"color":"#636efa","dash":"solid"},"mode":"lines","name":"", ...
and copy this JSON spec in a Plotly.js element to see the rendered result. Here is a Codepen for demonstration.
It's important to notice that a lot of JS charting libraries work by passing a JSON specification to them. Take a look at the documentation for echarts, Chart.js, Vega-lite, Victory ...
The takeaway here is this process can be replicated for integrating other charting libraries !
Our first task will be to replicate the streamlit.plotly_charts
by passing the JSON representation of the plotly Figure to the Javascript library for rendering.
Setup
Before pursuing this tutorial, you will need Python/Node.js installed on your system.
First clone the streamlit component-template, then move the template folder to your usual workspace.
git clone https://github.com/streamlit/component-template
cp -r component-template/template ~/streamlit-custom-plotly
From now on we assume every following shell command is done from the streamlit-custom-plotly
folder.
To pursue this, we need 2 terminals : one will run the Streamlit server, the other one a dev server containing the Javascript component.
- In a terminal, install the frontend dependencies and then run the component server.
cd my_component/frontend
npm install # Initialize the project and install npm dependencies
npm run start # Start the Webpack dev server
- In another terminal, create a Python environment with Streamlit ≥ 0.63 and then run the
__init__.py
script
conda create -n streamlit-custom python=3 streamlit # Create Conda env (or other Python environment manager)
conda activate streamlit-custom # Activate environment
streamlit run my_component/__init__.py # Run Streamlit app
You should see the Streamlit Hello world of Custom Components opening in a new browser :
Step 1 - "Hello world"
Head to your favorite code editor to edit the frontend code and only render "Hello world". Remove everything in my_component/frontend/src/MyComponent.tsx
and paste the following code to render a single Hello world block :
import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit } from "./streamlit"
function MyComponent() {
useEffect(() => Streamlit.setFrameHeight())
return <p>Hello world</p>
}
export default withStreamlitConnection(MyComponent)
We should also clean the running Streamlit in my_component/__init__.py
:
import streamlit as st
import streamlit.components.v1 as components
_component_func = components.declare_component(
"my_component",
url="http://localhost:3001",
)
def my_component():
return _component_func()
st.subheader("My Component")
my_component()
We're in Streamlit world, so livereload is enabled everywhere and the update in your browser should be instant.
If we want to understand things a bit, on the Python side we have a wrapper my_component()
function which calls a private _component_func()
. This private function returned by components.declare_component
accesses frontend resources from the url http://localhost:3001
, the other running dev server delivering the frontend component !
On the Javascript side, we define a functional component MyComponent
which returns a single block with "Hello world" inside. This is the output served by the dev server which Streamlit retrieves and renders in the browser.
We also make use of a React hook useEffect
which runs an anonymous callback function after the component has rendered to compute the height of the render, then update the height of the iframe containing the component. If you omit that function, your component will have a height of 0 and be invisible to the eye, but it will actually be there if you inspect the page source code with your browser's devtools.
Step 2 - Bidirectional communication between Python and React
The _component_func
in my_component/__init__.py
manages the call to the frontend web server to fetch the component. Any argument passed through this function is JSON-serialized to the React component. For example a Python Dict is passed as a JSON object to the dev web server and available in our frontend counterpart.
Time to add some parameters to the call :
import streamlit as st
import streamlit.components.v1 as components
_component_func = components.declare_component(
"my_component",
url="http://localhost:3001",
)
def my_component():
return _component_func(test="world") # <-- add some parameters in the call
st.subheader("My Component")
my_component()
And retrieve them on the React side in my_component/frontend/src/MyComponent.tsx
import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
// Your function has arguments now !
function MyComponent(props: ComponentProps) {
useEffect(() => Streamlit.setFrameHeight())
// Paramters from _component_func are stored in props.args
return <p>Hello {props.args.test}</p>
}
export default withStreamlitConnection(MyComponent)
Livereload in your browser should make the update already visible !
Here we set up data communication from Python to Javascript. To send data from Javascript to Python, we use the Streamlit.setComponentValue()
method, the value will then be stored as the return value for _component_func
:
import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
function MyComponent(props: ComponentProps) {
useEffect(() => Streamlit.setFrameHeight())
useEffect(() => Streamlit.setComponentValue(42)) // return value to Python after the component has rendered
return <p>Hello {props.args.test}</p>
}
export default withStreamlitConnection(MyComponent)
Then on the Python side :
import streamlit as st
import streamlit.components.v1 as components
_component_func = components.declare_component(
"my_component",
url="http://localhost:3001",
)
def my_component():
return _component_func(test="test") # value from Streamlit.setComponentValue is now returned !
st.subheader("My Component")
v = my_component()
st.write(v)
PS : you may see for a brief moment, while the component is being rendered, that v = None
. You can change the default value returned by _component_func
with the default
parameter : return _component_func(test="test", default=42)
Step 3 - Static Plotly.js plot
Time to spice things up, let's pass the JSON representation of our Plotly graph and render it with react-plotly.
First install the frontend dependencies :
cd my_component/frontend # make sure you are running this from the frontend folder !
npm install react-plotly.js plotly.js @types/react-plotly.js
Test the installation using the react-plotly quickstart code in my_component/frontend/src/MyComponent.tsx
:
import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
import Plot from "react-plotly.js" // new dependency
function MyComponent(props: ComponentProps) {
useEffect(() => Streamlit.setFrameHeight())
// we just changed the return value of the functional component to the one from https://plotly.com/javascript/react/#quick-start
return (
<Plot
data={[
{
x: [1, 2, 3],
y: [2, 6, 3],
type: "scatter",
mode: "lines+markers",
marker: { color: "red" },
},
{ type: "bar", x: [1, 2, 3], y: [2, 5, 3] },
]}
layout={{ width: 400, height: 400, title: "A Fancy Plot" }}
/>
)
}
export default withStreamlitConnection(MyComponent)
Be greeted by your plotly.js plot !
NB : I got a FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
there, which I solved with a NODE_OPTIONS
variable https://stackoverflow.com/questions/53230823/fatal-error-ineffective-mark-compacts-near-heap-limit-allocation-failed-javas.
Now let's pass the Python figure and extract the JSON specification in the wrapper to push to the JS side. In my_component/__init__.py
:
import random
import plotly.express as px
import streamlit as st
import streamlit.components.v1 as components
_component_func = components.declare_component(
"my_component",
url="http://localhost:3001",
)
def my_component(fig):
return _component_func(spec=fig.to_json(), default=42)
st.subheader("My Component")
fig = px.scatter(x=random.sample(range(100), 50), y=random.sample(range(100), 50), title="My fancy plot")
v = my_component(fig)
st.write(v)
and then get it back on the Javascript side my_component/frontend/src/MyComponent.tsx
import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
import Plot from "react-plotly.js"
function MyComponent(props: ComponentProps) {
useEffect(() => Streamlit.setFrameHeight())
useEffect(() => Streamlit.setComponentValue(42))
const { data, layout, frames, config } = JSON.parse(props.args.spec)
return (
<Plot
data={data}
layout={layout}
frames={frames}
config={config}
/>
)
}
export default withStreamlitConnection(MyComponent)
Your plotly express plot should appear now :
Why did we pass the JSON as a string to reparse it on the JS side ? I actually initially used
fig.to_dict
to pass a Dict in_component_func
. It should then get serialized directly as a Javascript object, but it actually resulted inError serializing numpy.ndarray
, requiring converting the numpy array as Python lists so they can be considered JSON-serializable. I think there is aplotly.utils.PlotlyJSONEncoder
that does it for you but haven't tested it...
If you rerender the app, Streamlit will recreate the plot from scratch with new data points. We can put the data in cache instead for our example. _component_func
also has a key
parameter which prevents the component from remounting from scratch when the Streamlit app rerenders :
import random
import plotly.express as px
import streamlit as st
import streamlit.components.v1 as components
_component_func = components.declare_component(
"my_component",
url="http://localhost:3001",
)
def my_component(fig):
# add key to _component_func so component is not destroyed/rebuilt on rerender
return _component_func(spec=fig.to_json(), default=42, key="key")
# data in cache
@st.cache
def random_data():
return random.sample(range(100), 50), random.sample(range(100), 50)
st.subheader("My Component")
x, y = random_data()
fig = px.scatter(x=x, y=y, title="My fancy plot")
v = my_component(fig)
st.write(v)
Step 4 - Selection in Plotly.js
How do we bind callback functions to lasso selection in plotly.js ? Our interest is in using Streamlit.setComponentValue()
inside this callback to return the selected data points back to Streamlit.
The page shows the callback event should be listened on plotly_selected
, which has its own prop onSelected
in react-plotly. Define a handler to get back the information on selected points :
import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
import Plot from "react-plotly.js"
function MyComponent(props: ComponentProps) {
useEffect(() => Streamlit.setFrameHeight())
const handleSelected = function (eventData: any) {
Streamlit.setComponentValue(
eventData.points.map((p: any) => {
return { index: p.pointIndex, x: p.x, y: p.y }
})
)
}
const { data, layout, frames, config } = JSON.parse(props.args.spec)
return (
<Plot
data={data}
layout={layout}
frames={frames}
config={config}
onSelected={handleSelected}
/>
)
}
export default withStreamlitConnection(MyComponent)
You could process the returned points in your Python wrapper to only return index or coordinates, depending on your usecase :
import random
import plotly.express as px
import streamlit as st
import streamlit.components.v1 as components
_component_func = components.declare_component(
"my_component",
url="http://localhost:3001",
)
def my_component(fig):
points = _component_func(spec=fig.to_json(), default=[], key="key")
return points
@st.cache
def random_data():
return random.sample(range(100), 50), random.sample(range(100), 50)
st.subheader("My Component")
x, y = random_data()
fig = px.scatter(x=x, y=y, title="My fancy plot")
v = my_component(fig)
st.write(v)
You can now play with your plotly plot and get back selected data in Python :
There are still some problems here, for example there's no callback when unselecting the points using the onDeselect
prop, I'll let that as an exercise :).
Conclusion
Hope this small tutorial will help you start your new interactive charting library in Streamlit. You can quickly create very specific custom charts if you don't want to bother with a generic wrapper implementation, don't hesitate to play with this ! The plotly.js page even shows a cross-filter in multiple plots in the same component, so you can do your data manipulation in Python and extra visualization in Plotly.js.
Top comments (2)
Thanks for the tutorial. The code in the repo works well.
Question; Is there a way to make the Plotly chart persistent (save the state?)
Currently, everytime you select data on the chart (and it is passed back to python) the Streamlit page is run again, re-creating the chart. So - if for example you had zoomed into a particular area on the chart, or drawn lines on the chart - all of that disappears after selecting any data**(the data itself is cached, but interactive changes to the chart disappear).
I found this but, two problems.
Anyway you can update your code to remember the state of the char?
Hi! Hope you are doing well :)
my_component
call, can you ensure it's there?useState
hook to preserve data reactjs.org/docs/hooks-state.html. You can put this above theuseEffect
line, and do something like (I have not tested this, there may be some errors to the gist! Plus, if you're using Typescript you may have to use the Plotly specific types on the useState specification for the compiler to not cry)Hope this helps, good luck
Fanilo