How to refactor a common Dash app to flux/redux
We've seen how to use the flux/redux architecture with Dash. The next question is: how to refactor a common dash app, to a dash app that use a flux/redux pattern. Without breaking it. Let's answer it.
Our starting point is a minimal application. It contains a single page and 3 UI components whose behaviors are chained:
A typical scenario is :
- You select a country
- In reaction to this action, dash populate the options of the city dropdown
- You select a city
- In reaction to this action, dash enable the submit button
- You submit your choice
Here is the code:
import dash
import dash_bootstrap_components as dbc
from dash import html, dcc, Output, Input, State
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
CITIES_BY_COUNTRY = {
"France": ["Paris", "Lyon", "Marseille"],
"USA": ["New York", "Los Angeles", "Chicago"],
"Japan": ["Tokyo", "Kyoto", "Osaka"],
}
app.layout = html.Div(
[
html.H1("Select Location", className="mb-4"),
html.Form(
[
html.Label("Country"),
dcc.Dropdown(
id="country-dropdown",
options=[{"label": c, "value": c} for c in CITIES_BY_COUNTRY],
placeholder="Select a country",
clearable=False,
),
html.Label("City"),
dcc.Dropdown(
id="city-dropdown",
placeholder="Select a city",
disabled=True,
clearable=False,
className="mt-3",
),
dbc.Button(
"Submit",
id="submit-button",
className="mt-3",
color="primary",
disabled=True,
),
]
),
html.Div(id="result-output"),
],
style={"margin": "16px"},
)
@app.callback(
Output("city-dropdown", "options"),
Output("city-dropdown", "value"),
Output("city-dropdown", "disabled"),
Input("country-dropdown", "value"),
prevent_initial_call=True,
)
def on_select_country(selected_country):
cities = CITIES_BY_COUNTRY[selected_country]
return [{"label": city, "value": city} for city in cities], None, False
@app.callback(
Output("submit-button", "disabled"),
Input("city-dropdown", "value"),
prevent_initial_call=True,
)
def on_select_city(selected_city: str):
return False
@app.callback(
Output("result-output", "children"),
Input("submit-button", "n_clicks"),
State("country-dropdown", "value"),
State("city-dropdown", "value"),
prevent_initial_call=True,
)
def on_click_submit_form(n_clicks, country, city):
return f"You selected {city}, {country}."
if __name__ == "__main__":
app.run(debug=True)
Initializing the scaffolding
Create a file state.py
with the following content:
from enum import Enum
INITIAL_STATE = {}
class Action(Enum):
...
def reduce(state: dict, action: Action, payload=None) -> dict:
raise NotImplementedError(f"Reducer for {action} isn't implemented.")
In app.py
, add the store that will hold the state in the layout:
...
app.layout = html.Div(
[
dcc.Store(id="state", data=INITIAL_STATE),
html.H1("Select Location", className="mb-4"),
...
Then create the downstream callback at the bottom of app.py
:
@app.callback(
Input("state", "data"),
)
def on_update_state(state: dict):
print(state)
The code we added has no impact on the behavior of the app. And we are ready to put the reduce in action.
Transitioning callbacks one by one
For each callback, the idea is to:
- Put the "Output fields" in the state
- Declare the state as a "State input" of the callback
- Add the state as parameter of the callback
- Create an action - and implement it's reducer
- Compute the reduced state in the callback
- Move the "Output fields" of the callback in the downstream callback
- Implement the selectors in the downstream callback
- Declare the state as an Output of the callback
- Return the reduced state
- Remove the State inputs that aren't used anymore in the callback
It's easier to start from the beginning of the flow
- because what comes next builds on top what comes before.
So let's start with the first callback: on_select_country
.
As a reminder, this is our callback:
@app.callback(
Output("city-dropdown", "options"),
Output("city-dropdown", "value"),
Output("city-dropdown", "disabled"),
Input("country-dropdown", "value"),
prevent_initial_call=True,
)
def on_select_country(selected_country):
cities = CITIES_BY_COUNTRY[selected_country]
return [{"label": city, "value": city} for city in cities], None, False
Introduce the output fields in the state and initialize the values as they are defined in the DOM:
INITIAL_STATE = {
"city-dropdown": {
"options": [],
"value": None,
"disabled": True,
}
}
Add the state as a "State input" of the callback:
@app.callback(
Output("city-dropdown", "options"),
Output("city-dropdown", "value"),
Output("city-dropdown", "disabled"),
Input("country-dropdown", "value"),
State("state", "data"), # <-- here
prevent_initial_call=True, # and here
) # vvvvvvvvvvv
def on_select_country(selected_country, state: dict):
...
Create an action:
class Action(Enum):
SELECT_COUNTRY = auto()
Implement it's reducer (you can copy what is done in the callback):
def reduce(state: dict, action: Action, payload=None) -> dict:
if action == Action.SELECT_COUNTRY:
cities = CITIES_BY_COUNTRY[payload]
return state | {
"city-dropdown": {
"options": [{"label": city, "value": city} for city in cities],
"value": None,
"disabled": False,
}
}
Then compute the reduced state in the callback:
@app.callback(
Output("city-dropdown", "options"),
Output("city-dropdown", "value"),
Output("city-dropdown", "disabled"),
Input("country-dropdown", "value"),
State("state", "data"),
prevent_initial_call=True,
)
def on_select_country(selected_country, state: dict):
cities = CITIES_BY_COUNTRY[selected_country]
state = reduce(state, Action.SELECT_COUNTRY) # <-- NEW LINE
return [{"label": city, "value": city} for city in cities], None, False
We're almost done. The app doesn't use the state yet - but it's still working.
Branching
This time, we won't avoid breaking the application. Let's be succinct:
- Move the outputs of the callback in the downstream callback
- Implement the selectors in the downstream callback
- Add the state as the output of the callback
- Return the reduced state
@app.callback(
Output("state", "data"),
Input("country-dropdown", "value"),
State("state", "data"),
prevent_initial_call=True,
)
def on_select_country(selected_country, state: dict):
return reduce(state, Action.SELECT_COUNTRY, selected_country)
...
@app.callback(
Output("city-dropdown", "options"),
Output("city-dropdown", "value"),
Output("city-dropdown", "disabled"),
Input("state", "data"),
)
def on_update_state(state: dict):
return (
state["city-dropdown"]["options"],
state["city-dropdown"]["value"],
state["city-dropdown"]["disabled"],
)
Run the app:
python app.py
The first callback now use the state and a flux/redux architecture.
dcc.Dropdown(
id="city-dropdown",
placeholder="Select a city",
disabled=True, # <-- can be removed
clearable=False,
),
Now, repeat the operation for the other callbacks.
Closing thoughts
You will need to mark allow duplicate : Output("state", "data", allow_duplicate=True),
There is no easy way to get the debugging comfort provided by redux dev tool. But you can log inside the reducer and in the downstream callback to collect insights of what's going on.
On callback that takes decision based on the state of the DOM, you can finally remove these inputs:
@app.callback(
Output("state", "data"),
Input("submit-button", "n_clicks"),
State("country-dropdown", "value"), # <- not necessary anymore
State("city-dropdown", "value"), # <- not necessary anymore
State("state", "date"),
prevent_initial_call=True,
)
def on_click_submit_form(n_clicks, country, city, state: dict):
return reduce(state, Action.DISPLAY_RESULT)
This visualisation compare the callback flow of the two architectures.
It makes obvious that the flux/redux architecture is more involved - but it scales better. For simple use cases, the dash flow is fine. For rich use cases, you need to break things down in order to allow local reasoning and clear intent.
Because Python as real types, we could even use commands. This way:
@dataclass(frozen=True)
class Action:
...
@dataclass(frozen=True)
class SelectCity(Action):
city: str
def reduce(state: dict, action: Action, payload=None) -> dict:
print("reduce", action, payload, state)
if isinstance(action, SelectCity):
return state | {
"city-dropdown": {
**state["city-dropdown"],
"value": payload.city,
}
}
This provides several advantages: - easier refactoring of the payloards - enables type checking - less hard coded values
But: - this is maybe going to far - people familiar with redux will understand more easily the current way it's done
I think that using dash this way is pushing it too far. Dash is fine for fast prototyping. When the app get richer, it becomes unmanageable. Summoning the flux/redux is a way to break it down, and add more clarity. But if you plan for a complex app, better starting with react directly.
It's the trap with such frameworks. It makes things easier in the beginning. This allows you to go faster then. But when complexity, you start fighting with it. The architectural patterns that made sens for prototyping may not be relevant at scale. You try to make the pattern evolve, but these frameworks are opinionated. And you start spending more and more time trying to tweak it to fit your needs.
Leave a reply