Boilerplate 'starter app' with state in URL and state-aware hot-reloading

I’ve worked out a little pattern for building simple Dash apps that I wanted to share with the community. It addresses a few of my personal requirements for apps:

  1. the state of the app is stored in the URL
    a. this means that the browser’s back and forward buttons work to navigate app state
    b. I can bookmark and/or email specific app states without needing to build a feature in the app for this
    c. as I develop the app, it hot-reloads to the same state that I was in before I made my change
  2. the different states of my app don’t all have to have all of my inputs in the DOM all the time with some of them hidden with CSS and some visible
  3. everything happens in one big callback, so I don’t need to mess with callback inputs and outputs very much

It has some notable shortcomings which I want to highlight up front, though:

  1. the entire app contents are recomputed/sent to the client on every interaction, so it’s mostly useful for when this process is fast or cacheable on the server (this is basically how Streamlit works) and that the contents aren’t so big that network delays are apparent
  2. the high-level app state is stored in the URL, so it must be reasonably short
  3. for the back/forward navigation to work well, the “state logic” function must ensure that the URL matches the current contents of the UI, with no additional keys, no unknown values etc. This is important to do in general to avoid strange bugs and/or malicious input anyway.
  4. if people are bookmarking states, new versions of the app must consume old versions of the URLs, but this is regular web-app practice
  5. this pattern works best when network delays are reasonably short, because every interaction causes two callbacks to fire: one to process the URL, which returns and then fires the main callback.

I think that there are many apps which fit into this basic pattern, either early in their lifecycle or just because they’re simple, for example if you have a graph and a table and 2 dropdowns and 3 radio buttons or something, this pattern is perfect… I hope this pattern is helpful for some people :slight_smile:

Here’s the stripped down version, and below that I have a simple ‘filled in’ example:

from collections import defaultdict
from urllib.parse import urlencode, parse_qsl
from dash import Dash, html, dcc, Input, Output, callback_context, ALL

app = Dash(__name__)

# app layout is basically just a container!
app.layout = html.Div([html.Div(id="content"), dcc.Location(id="location")])

def sanitize_state(state):
    # missing keys in the state will return empty strings instead of errors
    state = defaultdict(str, state)

    ### YOUR STATE LOGIC HERE

    # clear out empty keys from the URL
    for k_to_del in [k for k in state if not state[k]]:
        del state[k_to_del]
    return state


@app.callback(Output("location", "hash"), Input(dict(type="state", id=ALL), "value"),
                prevent_initial_call=True)
def update_hashpath(_):
    state = {
        input["id"]["id"]: input["value"] for input in callback_context.inputs_list[0]
    }
    return "#" + urlencode(sanitize_state(state))

# most of the action happens in this big callback
@app.callback(Output("content", "children"), Input("location", "hash"))
def update_content(hashpath):
    state = sanitize_state(parse_qsl(hashpath.lstrip("#")))

    ### YOUR UI HERE as a function of the contents of `state`
    contents = []

    return html.Div(className="wrapper", children=contents)


if __name__ == "__main__":
    app.run_server(debug=True)

Here’s what the callback graph looks like… note that although there is in principle a circularity problem because all of the components which could generate the leftmost input are created by the rightmost callback, this is is hidden from Dash because the rightmost callback writes to children and things work out:

The basic idea is that your app’s UI is generated in update_contents() and the state is sanitized in sanitize_state() and the pattern manages the URL bit. The update_hashpath() callback as written only handles dcc components that store their value in the value prop but the signature can be expanded to handle other props as needed. update_contents() can return just about anything, including a variable set of components with compound IDs that can be matched by update_hashpath() to be stored in the URL.

Here’s a worked example of an app with two “modes”: Cities and Countries. As I switch between them you can see that the UI changes between having a dropdown for countries or a radio-button for cities, and the display changes. Nothing too complicated, but note that in Cities mode, the Countries dropdown isn’t in the layout, and there’s no error and the callback still fires. Note also that that the back button works. I can also edit the URL directly if I want, and it corrects errors, and I can refresh the page any time and the state stays the same.

app

from collections import defaultdict
from urllib.parse import urlencode, parse_qsl
from dash import Dash, html, dcc, Input, Output, callback_context, ALL

app = Dash(__name__, suppress_callback_exceptions=True)

# app layout is basically just a container!
app.layout = html.Div([html.Div(id="content"), dcc.Location(id="location")])


modes = ["Cities", "Countries"]
cities = ["New York", "London"]
countries = ["Canada", "France"]


def sanitize_state(state):
    # missing keys in the state will return empty strings instead of errors
    state = defaultdict(str, state)

    ### YOUR STATE LOGIC HERE

    # make sure we've always got a valid mode and sub-mode
    if state["mode"] not in modes:
        state["mode"] = modes[0]
    if state["mode"] == "Cities":
        if state["city"] not in cities:
            state["city"] = cities[0]
    else:
        state["city"] = ""
    if state["mode"] == "Countries":
        if state["country"] not in countries:
            state["country"] = countries[0]
    else:
        state["country"] = ""

    # clear out empty keys from the URL
    for k_to_del in [k for k in state if not state[k]]:
        del state[k_to_del]
    return state


@app.callback(Output("location", "hash"), Input(dict(type="state", id=ALL), "value"),
                prevent_initial_call=True)
def update_hashpath(_):
    state = {
        input["id"]["id"]: input["value"] for input in callback_context.inputs_list[0]
    }
    return "#" + urlencode(sanitize_state(state))


@app.callback(Output("content", "children"), Input("location", "hash"))
def update_content(hashpath):
    state = sanitize_state(parse_qsl(hashpath.lstrip("#")))

    ### YOUR UI HERE
    # let's assume we have a top-level "mode" dropdown
    contents = [
        dcc.Dropdown(
            id=dict(type="state", id="mode"),
            value=state["mode"],
            options=modes,
        )
    ]

    # based on the mode we're in, we want to show a different UI
    if state["mode"] == "Cities":
        contents.append(
            dcc.Dropdown(
                id=dict(type="state", id="city"),
                value=state["city"],
                options=cities,
            )
        )
        contents.append(f"(content related to city={state['city']})")
    elif state["mode"] == "Countries":
        contents.append(
            dcc.RadioItems(
                id=dict(type="state", id="country"),
                value=state["country"],
                options=countries,
            )
        )
        contents.append(f"(content related to country={state['country']})")
    return contents


if __name__ == "__main__":
    app.run_server(debug=True)
8 Likes