Dependent Dropdowns Session Storage Wiped Out on Refresh

I am having an issue with dependent / chained dropdowns (dcc.Dropdown) and their options and values not being kept in session storage.

Description:

  • 3 dropdowns in total, and all three are multi=True
  • Drop down 1 is not dependent on anything, and will start with NO value selected, but options are there.
  • Once an value for 1 is selected, options get populated for drop down # 2 and then the user can make a selection.
  • If the user updates the value for dropdown 1, I have to:
    1. Update the options for dropdown 2
    2. Update the value for drop down 2 if the user had made a selection that’s no longer valid
  • The same pattern would exist for the 3rd drop down, where it’s options & potentially values are wiped changed based on dropdown 2.

The UI is working correctly until I refresh the browser. The session storage values are wiped out for both drop down 2 and drop down 3.

Below is a toy example of my actual app. I think the issue is with the two callbacks that update Output(dd2, ‘value’) and Output(dd3, ‘value’). If I take those out the session storage doesn’t get wiped out, BUT the app doesn’t function correctly since values are still there that are potentially no longer valid.

In order to see an incorrect behavior, you can do the following:

  1. Select record_1 and record_2 in the 1st drop down
  2. Select A and C from the 2nd drop down
  3. Remove record_1 from the 1st drop down. This should update the 2nd drop down’s options AND values. Should remove A & B as options and should remove A from the value.

Ater Step # 1 & 2, the app & session storage (inspect → application) look great
Then after Step # 3, the app has the correct message. However, the session storage is wiped out for the 2nd drop down for some reason.

Below is my code. I’ll try and post screenshots as well, but for some reason it’s not working

import dash.exceptions
from dash import Dash, dcc, html, Input, Output, State
import pandas as pd

app = Dash(__name__)

# 1st a 2nd drop-down option (2nd gets filtered based on 1st choice)
df1 = pd.DataFrame(
    data=[
        {'option1': 'record_1', 'option2': 'A'},
        {'option1': 'record_1', 'option2': 'B'},
        {'option1': 'record_2', 'option2': 'C'},
        {'option1': 'record_2', 'option2': 'D'},
    ]
)

# 3rd drop-down options (filtered by 2nd drop down selection)
df2 = pd.DataFrame(
    data=[
        {'option2': 'A', 'option3': 'A1'},
        {'option2': 'A', 'option3': 'A2'},
        {'option2': 'B', 'option3': 'B1'},
        {'option2': 'B', 'option3': 'B2'},
        {'option2': 'C', 'option3': 'C1'},
        {'option2': 'C', 'option3': 'C2'},
        {'option2': 'D', 'option3': 'D1'},
        {'option2': 'D', 'option3': 'D2'},
    ]
)


# Used to generate options for 2nd drop down, which are based on selection in 1st drop down
def generate_options2(option1):
    return df1[df1.option1.isin(option1)].option2.unique()


# Used to generate options for 3rd drop down, which are based on selections in 1st drop down
def generate_options3(option2):
    return df2[df2.option2.isin(option2)].option3.unique()


# Layout of app
app.layout = html.Div([
    
    # This has no dependencies, just pulls the data as-is to populate the initial options
    dd1 := dcc.Dropdown(
        id="dd-option1",
        options=df1.option1.unique(),
        multi=True,
        persistence="true",
        persistence_type="session",
    ),
    # This has a dependency on dd1, where the options shown and values can change based on selection
    dd2 := dcc.Dropdown(
        id="dd-option2",
        multi=True,
        persistence="true",
        persistence_type="session",
    ),
    # This has a dependency on dd2, where the options shown and values can change based on selection
    dd3 := dcc.Dropdown(
        id="dd-option3",
        multi=True,
        persistence="true",
        persistence_type="session",
    ),
    html.Br(),
    html.Div(id='selections')
])


# Updates the options for the 2nd drop-down based on value of 1st
@app.callback(
    Output(dd2, 'options'),
    Input(dd1, 'value')
)
def update_dd2_options(dd1_val):
    if dd1_val is None:
        raise dash.exceptions.PreventUpdate
    return generate_options2(dd1_val)


# Updates the value of the 2nd drop-down in case the options change
@app.callback(
    Output(dd2, 'value'),
    Input(dd2, 'options'),
    State(dd2, 'value')
)
def update_dd2_value(dd2_opt, dd2_val):
    if dd2_opt is None or dd2_val is None:
        raise dash.exceptions.PreventUpdate
    dd2_new_val = [d for d in dd2_val if d in dd2_opt]
    if sorted(dd2_new_val) == sorted(dd2_val):
        return dash.no_update
    return dd2_new_val


# Updates the options for the 3rd drop-down based on value of 2nd
@app.callback(
    Output(dd3, 'options'),
    Input(dd2, 'value')
)
def update_dd3_options(dd2_val):
    if dd2_val is None:
        raise dash.exceptions.PreventUpdate
    return generate_options3(dd2_val)


# Updates the value of the 3rd drop-down in case the options change
@app.callback(
    Output(dd3, 'value'),
    Input(dd3, 'options'),
    State(dd3, 'value')
)
def update_dd3_value(dd3_opt, dd3_val):
    if dd3_opt is None or dd3_val is None:
        raise dash.exceptions.PreventUpdate
    dd3_new_val = [d for d in dd3_val if d in dd3_opt]
    if sorted(dd3_new_val) == sorted(dd3_val):
        return dash.no_update
    return dd3_new_val


# Updates message to show current values
@app.callback(
    Output('selections', 'children'),
    Input('dd-option1', 'value'),
    Input('dd-option2', 'value'),
    Input('dd-option3', 'value')
)
def update_selection_message(dd1_val, dd2_val, dd3_val):
    dd1_message = html.P("DD1 has no value yet") if dd1_val is None else html.P(f"DD1 value: {dd1_val}")
    dd2_message = html.P("DD2 has no value yet") if dd2_val is None else html.P(f"DD2 value: {dd2_val}")
    dd3_message = html.P("DD3 has no value yet") if dd3_val is None else html.P(f"DD3 value: {dd3_val}")
    return [
        dd1_message,
        dd2_message,
        dd3_message
    ]


app.run_server(debug=True)

I think this is the callback that’s causing the session storage to get wiped out in the example I listed.

If I take this out, the app doesn’t run correctly (i.e. values will be shown in the UI that are no longer valid). But with it included the session storage is being wiped out.

# Updates the value of the 2nd drop-down in case the options change
@app.callback(
    Output(dd2, 'value'),
    Input(dd2, 'options'),
    State(dd2, 'value')
)
def update_dd2_value(dd2_opt, dd2_val):
    if dd2_opt is None or dd2_val is None:
        raise dash.exceptions.PreventUpdate
    dd2_new_val = [d for d in dd2_val if d in dd2_opt]
    if sorted(dd2_new_val) == sorted(dd2_val):
        return dash.no_update
    return dd2_new_val