Dropdown persistence does not work when its value is filled from a callback

I have a slave dropdown list in my application whose options change based on the value selected in a master dropdown. This part is working as expected. I would also like to add a button which, when the user selects it, causes a default value to be written to the dropdown from a callback (overriding any existing selected value). This works fine too. However, this value will not persist if I switch the master dropdown selection to another value and then back to the original selection. Persistence works fine as long as the slave drowpdown contains a user selected value.

I modified a sample application from the Plotly website to illustrate my issue. If you click the ‘default’ button, it will overwrite the ‘neighborhood’ dropdown with a default value. If you then change the city and come back to the previously selected city, the neighborhood drodown is blank. However, if you manually select a neighborhood, change the city and come back - the neighborhood value persists.

import dash
from dash.dependencies import Input, Output
import dash_core_components as dcc
import dash_html_components as html
import dash_bootstrap_components as dbc

CITIES = ['Boston', 'London', 'Montreal']
NEIGHBORHOODS = {
    'Boston': ['Back Bay', 'Fenway', 'Jamaica Plain'],
    'London': ['Canary Wharf', 'Hackney', 'Kensington'],
    'Montreal': ['Le Plateau', 'Mile End', 'Rosemont']
}

DEFAULT_NEIGHBORHOODS = {
    'Boston': 'Back Bay',
    'London': 'Kensington',
    'Montreal': 'Le Plateau'
}

app = dash.Dash(__name__,  external_stylesheets=[dbc.themes.BOOTSTRAP])

app.layout = html.Div([
    'Choose a city:',
    dcc.Dropdown(
        id='persisted-city',
        value='Montreal',
        options=[{'label': v, 'value': v} for v in CITIES],
        persistence=True
    ),
    html.Br(),
    'correlated persistence - choose a neighborhood:',
    html.Div([
            dcc.Dropdown(id='neighborhood'),
        ], id='neighborhood-container'),
    html.Br(),
    dbc.Button('default', id='default_neighborhood'),
    html.Div(id='persisted-choices')
])


@app.callback(
    Output('neighborhood-container', 'children'),
    [Input('persisted-city', 'value'),
     Input('default_neighborhood', 'n_clicks')]
)
def set_neighborhood(city, default_button_clicks):
    neighborhoods = NEIGHBORHOODS[city]
    ctx = dash.callback_context
    triggered_input = ctx.triggered[0]['prop_id'].split('.')[0]
    value=''
    if triggered_input == 'default_neighborhood':
        value = DEFAULT_NEIGHBORHOODS[city]

    return dcc.Dropdown(
        id='neighborhood',
        value=value,
        options=[{'label': v, 'value': v} for v in neighborhoods],
        multi=False,
        persistence_type='session',
        persistence=city
    )


@app.callback(
    Output('persisted-choices', 'children'),
    [Input('persisted-city', 'value'),
     Input('neighborhood', 'value')]
)
def set_out(city, neighborhood):
    return 'You chose: {}, {}'.format(neighborhood, city)


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

1 Like

Would appreciate if anyone can help with this. Thanks!

1 Like

Hi @chander
Every time a user clicks on the master/top dropdown the callback is fired and therefore it return the dcc.Dropdown() with the value inside it being an empty string because you set value=" ".

There are several ways around this. Here is one:

from dash.exceptions import PreventUpdate

def set_neighborhood(city, default_button_clicks):
    neighborhoods = NEIGHBORHOODS[city]
    ctx = dash.callback_context
    triggered_input = ctx.triggered[0]['prop_id'].split('.')[0]
    value=''
    if default_button_clicks is None:
        return dcc.Dropdown(
            id='neighborhood',
            value=value,
            options=[{'label': v, 'value': v} for v in neighborhoods],
            multi=False,
            persistence_type='session',
            persistence=True
        )
    if triggered_input == 'default_neighborhood' or default_button_clicks >= 1:
        value = DEFAULT_NEIGHBORHOODS[city]

        return dcc.Dropdown(
            id='neighborhood',
            value=value,
            options=[{'label': v, 'value': v} for v in neighborhoods],
            multi=False,
            persistence_type='session',
            persistence=True
        )

Hope that accomplishes what you’re looking for,
Adam

I’ve had the same issue as @chander. @adamschroeder I tried running your example but it didn’t seem to fix the issue as far as I could tell?

As far as I can tell from the dash-renderer source, changes to a components props are recorded for persistence purposes when the component calls setProps. This is how components let dash-renderer know that something has changed and that it should trigger the relevant callbacks. But setProps is only called if a component changes its own props, not if it receives props as the result of callback output. I think this would explain the reported behaviour.

If my understanding is correct, it would likely require a change to the implementation of persistence, I don’t think you can make it work just by modifying Python code. Would be interested to hear from some of the Plotly team about whether it would be possible to support this. Could you for example call recordUiEdit for any observers of the component that made the call to setProps? Or would there be performance implications maybe? There are some cool potential applications if it could be made to work other than what’s posted in this thread such as having an alert appear only the first time a user visits a page (dismissed by button click + callback and then persisted).

4 Likes

Hi @adamschroeder, thanks for looking into this. I copied your code for the callback but with this change, the behavior seems a bit wonky. E.g. if you manually select a neighborhood that is not the default, switch to a different city and then back, the default neighborhood for the original shows up as selected.

Thanks, @tcbegley. From your analysis, it would appear that a change in the dash-renderer source is required to make this work correctly.

Even though I posted an example featuring a dropdown with single selection, I noticed the issue in my app on a dropdown with multi select and I would like to get persistence working in that configuration.

1 Like

Hi @chander,
I wasn’t really sure what you’re were trying to do with this web app. Therefore, I only solved for what you brought up at your original post:

“If you then change the city and come back to the previously selected city, the neighborhood drodown is blank”

What is your main goal with this? Do you want the user to click on the button and get a default neighborhood, but then also allow user to change neighborhood manually, and also have the app default back to user’s neighborhood choice if they change city?
Is there any way to make the app simpler?

Hi @adamschroeder,

In my application, I have a dropdown box with multiple selections enabled. The options in this dropdown is controlled by a master dropdown just like the example I posted. The user could physically select values from the dropdown or click some buttons to replace the selections with default selections.

When I change the master dropdown selection and then back to the original selection, I would like to see the slave dropdown selections persist from before the master dropdown change, regardless of whether they were physically selected by the user or generated from a button press.

Not sure if the above is clear. So, let me give you an multi selection example based on the code I posted:

  1. City = Montreal, Neighborhoods = None
  2. I select Rosemont.
    Neighborhoods = Rosemont
  3. I select the default button
    Neignborhoods = Rosemont, Le Plateau
  4. I change city to Boston and then back to Montreal
  5. What I see: Neighborhoods = Rosemont
    What I expect to see: Neighborhoods = Rosemont, Le Plateau

Does that make sense?

Now I get you, @chander,
I’m not sure. But I would suggest using

print(value), print (DEFAULT_NEIGHBORHOODS), print(value)

inside your function. That will allow you to see what is captured when the button is triggered or not. And based on that, hopefully you can get the value you need to auto-populate into the dropdown.