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:

Here is an overview of how this concepts are implemented.

app/pages/city.py:

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"],
        )

app/pages/city_state.py

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:

With the flux/redux pattern:

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 :

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.

app/pages/main_layout.py:

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"],
            ...
        )

app/pages/main_state.py:

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:

  1. Delete the callback app/pages/main_layout.py::on_page_load
  2. Start the app with python run.py
  3. Visit http://127.0.0.1:8050/
  4. Click Add a city button
  5. 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 :).

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:

  1. store the graph data in the page state, and generate the graph in the downstream callback.
  2. 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