How to use the flux/redux architecture with Dash
Have you ever said
Dash @*&#?!. With React it's simpler.
?
If yes, this article will interest you.
Here is how to use the flux/redux architecture with Dash.
I've developed a minimal application that shows the important concepts. You can get the code at https://github.com/petitapetitio/dash-with-redux.
In addition to introducing flux/redux, it also shows how to instanciate dash pages in a way that allow dependency injection and reduce usage of global variables.
Breaking down the architecture
The global mechanism can be seen this way:
In common Dash, the layout triggers callbacks. Callbacks do computations, and returns what needs to be modified in the layout.
In flux/redux Dash, the layout triggers callback too. Callback compute the next state by dispatching an action. The action is handled by a reducer. The callback return the new state. And a downstream callback catch any state modification, in order to update the layout.
It involves:
- States. A state is stored in a
dcc.Store
. The main state is in the over-arching layout, and each page can have its local state. - Reducers. A reducer is a pure function that compute the next state. It don't have side effects, it don't depend on global variables, and it don't mutate its inputs.
- Actions. An action comes with a payload and tell the reducer how to transform the state.
- Selectors. A selector is an abstraction over the state. It allows to compute values from the state. It allows reusability, expressiveness, and decoupling from the state.
- Downstream Callbacks. It's the callback that listen for state updates, and update the layout in consequence.
Here is an overview of how this concepts are implemented.
from app.pages.city_state import INITIAL_STATE, reduce, Action
...
def register_page_city(app):
register_page(
"city",
path=routes.CITY,
layout=html.Div(
id="city-layout",
children=[
dcc.Store(id="city-state", data=INITIAL_STATE), # <-- the page's state
...
],
),
)
...
@app.callback(
Output("country-dropdown", "value"),
Output("city-dropdown", "options"),
Output("city-dropdown", "value"),
Output("city-dropdown", "disabled"),
Output("population-input", "value"),
Output("comment-input", "value"),
Output("submit-button", "disabled"),
Output("visualize-button", "disabled"),
Input("city-state", "data"),
)
def on_update_state(state: dict): # <-- the page's downstream callback
return (
state["country"],
state["city-dropdown"]["options"],
state["city-dropdown"]["value"],
state["city-dropdown"]["disabled"],
state["population"],
state["comment"],
state["submit-button"]["disabled"],
state["visualize-button"]["disabled"],
)
INITIAL_STATE = { # <-- The page's initial state
"population": "",
...
}
class Action(Enum): # <-- The page's actions
SET_POPULATION = auto()
...
def reduce(state: dict, action: Action, payload=None) -> dict: # <-- The page's reducer
if action == Action.SET_POPULATION:
return state | {"population": payload}
...
raise NotImplementedError(f"Reducer for {action} isn't implemented.")
The callback flow looks this way:
In common dash:
- user actions triggers callbacks
- callbacks return values of what change in the DOM
With the flux/redux pattern:
- user actions triggers callbacks
- callbacks computes the next state value by giving actions to a reducer
- the new state is stored in the DOM
- a downstream callback catch any state modification and update the DOM
The right-side of the flux/redux Dash unfolding highlights the downstream callback.
Analysing the benefits and the drawbacks
Let's start with the drawbacks.
A first one is that you write more code. It's the price to pay to break things down. For tiny apps, it may be not worth it. But when your app gets rich, or you want to perform live validation on forms and provide early feedback on every move, it shows it's interest.
Another drawback is that there is no selective rendering. When a part of the state is updated, all the outputs of the downstream callback are refreshed.
Among the benefits :
- The core value of this pattern rely on the action and the reducer
- It allows to concentrate the logic into the reducer and have thin callbacks
- It brings testability (the reducer is a pure function)
- Actions are composable (again, because the reducer is pure)
- It decouple the state from the layout. This provides a better separation of concerns, and thus improve readability/scalability.
- Actions emphasis the intent, the what rather than how. If a callback leads to several actions, you see clearly how they are composed (example).
- Functions names can focus on the event that triggers them. The intent is expresse by the action.
- It's the functional core, imperative shell principle, applied to the front-end
They are pros and cons. It's not a silver bullet.
For a simple dash application, it won't be worth it.
If you get to a point you find yourself struggling with the logic of the app, this approach will help you to write more expressive code and facilitate local reasoning.
If you know from start you are building a rich app, you may want to start with another technology from the beginning. I'm very open to change my mind. Please prove me I'm wrong. But if find it lacks building custom components easily.
Edge-cases
How to handle navigation
In common Dash, navigating to a new page can be done in several ways.
By updating the href
attribute of a dcc.Location
:
# Somewhere in the layout:
# html.Button(id="go-to-page2-btn", children="Go to page 2")
@app.callback(
Output("url", "href", allow_duplicate=True),
Input("go-to-page2-btn", "n_clicks"),
prevent_initial_call=True,
)
def on_go_to_page2_clicked(n_clicks: int | None):
if n_clicks is None:
return dash.no_update
return "page2"
By putting a dcc.Location
in the children
property of a DOM element
# Somewhere in the layout:
# html.Button(id="go-to-page2-btn", children="Go to page 2"),
# html.Div(id="dummy")
@app.callback(
Output("dummy", "children"),
Input("go-to-page2-btn", "n_clicks"),
)
def on_go_to_page2_clicked(n_clicks: int | None):
if n_clicks is None:
return dash.no_update
return dcc.Location(id="redirect-location", "page2")
By setting the href
property of a dash bootstrap component's button:
dbc.Button("go", href="page2")
With flux/redux approach, navigation can be centralized in the state:
It brings uniformity, and it works well. But you need to be careful of a few things.
import dash
from dash import Dash, html, dcc, Output, Input, State
from app.pages.main_state import INITIAL_STATE, reduce, Action
def register_main(app: Dash):
app.layout = html.Div(
[
dcc.Location(id="url", refresh=True), # <-- Put a Location in the main layout
dcc.Store(id="main-state", storage_type="session", data=INITIAL_STATE), # <-- create a session-scoped state
html.Div(dash.page_container, id="page-container"),
...
]
)
"""
You want to be able to access the app from a url.
The trick here is to capture the location's href when it changes.
And only when it really changes.
You don't want to update the state when the callback is triggered with the value that is already in the state.
"""
@app.callback(
Output("main-state", "data"),
Input("url", "href"),
State("main-state", "data"),
)
def on_page_load(href: str, state: dict):
if href == state["url"]["href"]:
return dash.no_update
return reduce(state, Action.SET_URL, href)
"""
You control the location here, in the downstream callback
"""
@app.callback(
Output("url", "href", allow_duplicate=True), # <-- be careful, allow_duplicate=True is important
# ... other outputs ...
Input("main-state", "data"),
prevent_initial_call=True,
)
def on_update_state(state: dict):
return (
state["url"]["href"],
...
)
import urllib.parse
from enum import Enum, auto
from app import routes
from app.city import City
INITIAL_STATE = {
"url": {
"href": "",
"base": "",
},
...
}
class Action(Enum):
SET_URL = auto()
...
def reduce(state: dict, action: Action, payload=None) -> dict:
if action == Action.SET_URL:
href: str = payload
# resolve the url
# /city -> http://127.0.0.1:8050/city
if href.startswith("/"): href = state["url"]["base"] + href
url = urllib.parse.urlparse(href)
return state | {
"url": {
"href": href,
"base": f"{url.scheme}://{url.netloc}",
}
}
# ... other actions ...
raise NotImplementedError(f"Reducer for {action} isn't implemented.")
Also, it's all or nothing. If you start managing navigation through the state, every move should go through the state. You cannot combine it with using a dbc.Button("go", href="page2")
to navigate: the next state's update of the main layout will take you back to the previous page (ie the location that the state contains).
As an experience, try to break the demo app:
- Delete the callback
app/pages/main_layout.py::on_page_load
- Start the app with
python run.py
- Visit
http://127.0.0.1:8050/
- Click
Add a city
button - See the tragic
How to debounce user inputs
Because Dash batch user inputs, you receive incomplete inputs when the user type fast and your server is slow:
This has nothing to do with the flux/redux approach. But it's a problem you may encounter when working with Dash. It can be solved with debouncing: dcc.Input(id="comment-input", debounce=0.5)
.
Warning : dcc.Input accepts number for it's debounce argument since 2.12.0, but depending on the versions, it's expressed in secondes or in milliseconds.
How to integrate Dash Bootstrap Components
Dash Bootstrap Components is a library of Bootstrap components for dash app. It offers components like a toasts, modals and many more, that are easy to integrate with dash apps.
Components like modal and toast have a built-in close mechanism. The trick is that if you control opening from the state, don't forget to catch the "on_close" to sync the state. Else, it will open again on the next state update :).
- If your modal's header set the
close_button
property toTrue
, and catch when your modalis_open
property becomesFalse
. - If your toast set the
dismissable
property and displays a close button, catchn_dismiss
updates.
How to handle time-consuming updates
A major interest of dash is to create graphs.
Let's say you have a button visualize
.
When you click, it fetch data from an api.
And you want to plot the data.
We have 2 options:
- store the graph data in the page state, and generate the graph in the downstream callback.
- generate the graph in the DOM callback and return the figure without using the state (like in a common dash app)
The state can only hold serializable information. You cannot put the figure object in the state.
You can put the graph data in the state, and create the graph in the downstream callback. But this will recreate the graph on each state update. Creating a plot with 1M points takes 1.5 seconds on my computer. Recreating the graph on each interaction will slow the application. The graph will disappear and reappear — even when unrelated information changes. This is confusing for the user.
My recommendation is to not put the graph generation in the downstream callback.
Instead of:
# The DOM callback
@app.callback(
Output("city-state", "data", allow_duplicate=True),
Input("visualize-button", "n_clicks"),
State("city-state", "data"),
prevent_initial_call=True,
)
def on_click_visualize(n_clicks: int | None):
if n_clicks is None:
return dash.no_update
xs, ys = _fake_air_quality_over_time_data(1_000_000)
return reduce(state, Action.SET_VISUALISATION, {"xs": xs, "ys": ys})
# The downstream callback
@app.callback(
...,
Output("comment-input", "value"),
Output("visualization-div", "children"),
Input("city-state", "data"),
)
def on_update_state(state: dict):
print("city:on_update_state")
return (
...,
_generate_visualization(state["visualization"])
)
Prefer the common dash approach:
@app.callback(
Output("visualization-div", "children"),
Input("visualize-button", "n_clicks"),
prevent_initial_call=True,
)
def on_click_visualize(n_clicks: int | None):
if n_clicks is None:
return dash.no_update
xs, ys = _fake_air_quality_over_time_data(1_000_000)
fig = go.Figure(data=[go.Scatter(x=xs, y=ys)])
fig.update_layout(margin=dict(l=20, r=20, t=20, b=20))
fig.update_xaxes(title="Date")
fig.update_yaxes(title="Air quality")
return dcc.Graph(figure=fig)
The lesson here is that you don't need to put everything in the state. The more consistent your architecture is, the better. But you can combine the flux/redux approach with the common dash approach. In some special cases like this one, it's better to avoid the shared state.
How to execute actions when the page opens
One way is to implement a layout factory function (instead of a layout value):
def _layout_factory(id: Optional[int] = None):
# Perform initialization based on city's id value
# if visiting http://127.0.0.1:8050/city then id is None
# if visiting http://127.0.0.1:8050/city?id=0 then id is 0
return html.Div([...])
def register_page_city(app):
register_page(
"city",
path=routes.CITY,
layout=_layout_factory,
)
The limit of this approach is that you cannot access the state of the main layout. And you cannot update it as an output.
A simple way to execute actions when the page opens is to listen a
DOMContentLoaded-like event. Give an id to your page's layout, and listen its children
property:
def register_page_city(app):
register_page(
"city",
path=routes.CITY,
layout=html.Div(
id="city-layout", # <-- give an id to the page's layout
children=[
html.H1("Add a city"),
...
]
),
)
...
@app.callback(
Output("city-state", "data"),
Input("city-layout", "children"), # <-- listen to the page's layout `children` property update
State("url", "href"),
State("city-state", "data"),
# prevent_initial_call=True, <-- don't prevent initial call
)
def on_page_load(_, href: str, state: dict):
url = urllib.parse.urlparse(href)
city_id = int(url.query.split("id=")[1]) if url.query != "" else None
# perform any page initialization task based on the city_id
return new_state
Another approach consist in listening to the location's component href
updates. This one involves parsing the URL to check if it match the URL of the current page. This is more error-prone. You need a guard so you won't initialize page A when you load page B. But it works too:
@app.callback(
Output("city-state", "data"),
Input("url", "href"),
State("city-state", "data"),
prevent_initial_call=True,
)
def on_page_load(href: str, state: dict, main_state: dict):
url = urllib.parse.urlparse(href)
if url.path != routes.CITY:
return dash.no_update
city_id = int(url.query.split("id=")[1]) if url.query != "" else None
# perform any page initialization task
return new_state
Leave a reply