dash icon indicating copy to clipboard operation
dash copied to clipboard

[BUG] Callbacks triggered unnecessarily with MATCH (pattern-matching callbacks)

Open samlishak-artemis opened this issue 4 years ago • 8 comments

Describe your context

dash                          1.20.0
dash-bootstrap-components     0.12.2
dash-core-components          1.16.0
dash-html-components          1.1.3
dash-renderer                 1.9.1
dash-table                    4.11.3

Describe the bug

In the example below, the callback using MATCH is triggered for all matching components, even ones that haven't changed and don't need updating.

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State, MATCH, ALL

app = dash.Dash(__name__, suppress_callback_exceptions=True)

app.layout = html.Div(
    [
        html.Button("Add Filter", id="dynamic-add-filter", n_clicks=0),
        html.Div(id="dynamic-dropdown-container", children=[]),
    ]
)


@app.callback(
    Output("dynamic-dropdown-container", "children"),
    Input("dynamic-add-filter", "n_clicks"),
    State("dynamic-dropdown-container", "children"),
)
def display_dropdowns(n_clicks, children):
    new_element = html.Div(
        [
            dcc.Dropdown(
                id={
                    "type": "dynamic-dropdown",
                    "index": n_clicks,
                },
                options=[
                    {"label": i, "value": i} for i in ["NYC", "MTL", "LA", "TOKYO"]
                ],
                style={"width": "200px", "display": "inline-block"},
            ),
            dcc.Input(
                id={
                    "type": "dynamic-input",
                    "index": n_clicks,
                },
                type="number",
                style={"width": "200px", "display": "inline-block"},
            ),
            html.Div(
                id={
                    "type": "dynamic-output",
                    "index": n_clicks,
                }
            ),
        ]
    )
    children.append(new_element)
    return children


@app.callback(
    Output({"type": "dynamic-input", "index": MATCH}, "value"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
)
def set_initial_value(city):
    values = {
        "NYC": 10,
        "MTL": 20,
        "LA": 30,
        "TOKYO": 40,
        None: None,
    }
    return values[city]


@app.callback(
    Output({"type": "dynamic-output", "index": MATCH}, "children"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
    State({"type": "dynamic-dropdown", "index": MATCH}, "id"),
)
def display_output(value, id):
    return html.Div("Dropdown {} = {}".format(id["index"], value))


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

Example:

  1. Select a value from the dropdown. The input next to it is populated with an initial value
  2. Change that value (e.g. just increment it by 1)
  3. Click "Add Filter". The new row is added correctly, but the first input is reset to its initial value even though the first dropdown has not changed.

Expected behavior

The set_initial_value function should only be called for components that have changed. It seems to be called for all matching components even if only one of them has changed.

samlishak-artemis avatar Jul 05 '21 15:07 samlishak-artemis

You can prevent the initial update with the prevent_initial_call parameter. Works fine with this code:

@app.callback(
    Output({"type": "dynamic-input", "index": MATCH}, "value"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
    prevent_initial_call=True,
)
def set_initial_value(city):
    values = {
        "NYC": 10,
        "MTL": 20,
        "LA": 30,
        "TOKYO": 40,
        None: None,
    }
    return values[city]

nickmelnikov82 avatar Apr 19 '22 15:04 nickmelnikov82

prevent_initial_call only prevents callbacks at the start of the app. If components are dynamically added and if they match the callback, then the callback will be triggered. My assumption is that whatever prevents the call during startup does not occur after the app has loaded. Basically, going from a none state (or undefined) to a component value is registered as a change rather than the change occurring because of an event trigger.

mbworth avatar Jul 13 '24 00:07 mbworth

Hi - we are tidying up stale issues and PRs in Plotly's public repositories so that we can focus on things that are most important to our community. If this issue is still a concern, please add a comment letting us know what recent version of our software you've checked it with so that I can reopen it and add it to our backlog. (Please note that we will give priority to reports that include a short reproducible example.) If you'd like to submit a PR, we'd be happy to prioritize a review, and if it's a request for tech support, please post in our community forum. Thank you - @gvwilson

gvwilson avatar Jul 25 '24 13:07 gvwilson

This is still an issue using the same code and text as above: " Describe your context

dash==2.17.0
dash_core_components==2.0.0
dash_html_components==2.0.0

Describe the bug

In the example below, the callback using MATCH is triggered for all matching components, even ones that haven't changed and don't need updating.

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State, MATCH, ALL

app = dash.Dash(__name__, suppress_callback_exceptions=True)

app.layout = html.Div(
    [
        html.Button("Add Filter", id="dynamic-add-filter", n_clicks=0),
        html.Div(id="dynamic-dropdown-container", children=[]),
    ]
)


@app.callback(
    Output("dynamic-dropdown-container", "children"),
    Input("dynamic-add-filter", "n_clicks"),
    State("dynamic-dropdown-container", "children"),
)
def display_dropdowns(n_clicks, children):
    new_element = html.Div(
        [
            dcc.Dropdown(
                id={
                    "type": "dynamic-dropdown",
                    "index": n_clicks,
                },
                options=[
                    {"label": i, "value": i} for i in ["NYC", "MTL", "LA", "TOKYO"]
                ],
                style={"width": "200px", "display": "inline-block"},
            ),
            dcc.Input(
                id={
                    "type": "dynamic-input",
                    "index": n_clicks,
                },
                type="number",
                style={"width": "200px", "display": "inline-block"},
            ),
            html.Div(
                id={
                    "type": "dynamic-output",
                    "index": n_clicks,
                }
            ),
        ]
    )
    children.append(new_element)
    return children


@app.callback(
    Output({"type": "dynamic-input", "index": MATCH}, "value"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
)
def set_initial_value(city):
    values = {
        "NYC": 10,
        "MTL": 20,
        "LA": 30,
        "TOKYO": 40,
        None: None,
    }
    return values[city]


@app.callback(
    Output({"type": "dynamic-output", "index": MATCH}, "children"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
    State({"type": "dynamic-dropdown", "index": MATCH}, "id"),
)
def display_output(value, id):
    return html.Div("Dropdown {} = {}".format(id["index"], value))


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

Example:

Select a value from the dropdown. The input next to it is populated with an initial value Change that value (e.g. just increment it by 1) Click "Add Filter". The new row is added correctly, but the first input is reset to its initial value even though the first dropdown has not changed.

Expected behavior

The set_initial_value function should only be called for components that have changed. It seems to be called for all matching components even if only one of them has changed. "

Although this can be avoided by using prevent_initial_call=True in the callback, I believe that this is still unexpected behavior. There may also be a relation to Issue #2371 as well, in which a similar confusion around pattern-matching callbacks can be found. (https://github.com/plotly/dash/issues/2371)

kevin-gett avatar Dec 06 '24 19:12 kevin-gett

@gvwilson Will this issue be reopened? It is still apparent on Dash 2.18.2.

kevin-gett avatar Dec 12 '24 20:12 kevin-gett

@T4rk1n when you have time can you please check if this issue exists in Dash 3.0?

gvwilson avatar Dec 16 '24 19:12 gvwilson

@gvwilson, I tested it with Dash 3.0, and the issue persists. Using the code provided by @kevin-gett to print the callback trigger, I observed that adding a new element triggers all existing elements with an ID of None and props {}

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State, MATCH, ALL

app = dash.Dash(__name__, suppress_callback_exceptions=True)

app.layout = html.Div(
    [
        html.Button("Add Filter", id="dynamic-add-filter", n_clicks=0),
        html.Div(id="dynamic-dropdown-container", children=[]),
    ]
)


@app.callback(
    Output("dynamic-dropdown-container", "children"),
    Input("dynamic-add-filter", "n_clicks"),
    State("dynamic-dropdown-container", "children"),
)
def display_dropdowns(n_clicks, children):
    new_element = html.Div(
        [
            dcc.Dropdown(
                id={
                    "type": "dynamic-dropdown",
                    "index": n_clicks,
                },
                options=[
                    {"label": i, "value": i} for i in ["NYC", "MTL", "LA", "TOKYO"]
                ],
                style={"width": "200px", "display": "inline-block"},
            ),
            dcc.Input(
                id={
                    "type": "dynamic-input",
                    "index": n_clicks,
                },
                type="number",
                style={"width": "200px", "display": "inline-block"},
            ),
            html.Div(
                id={
                    "type": "dynamic-output",
                    "index": n_clicks,
                }
            ),
        ]
    )
    children.append(new_element)
    return children


@app.callback(
    Output({"type": "dynamic-input", "index": MATCH}, "value"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
)
def set_initial_value(city):
    print(dash.callback_context.triggered_id)
    print(dash.callback_context.triggered_prop_ids)
    values = {
        "NYC": 10,
        "MTL": 20,
        "LA": 30,
        "TOKYO": 40,
        None: None,
    }
    return values[city]


@app.callback(
    Output({"type": "dynamic-output", "index": MATCH}, "children"),
    Input({"type": "dynamic-dropdown", "index": MATCH}, "value"),
    State({"type": "dynamic-dropdown", "index": MATCH}, "id"),
)
def display_output(value, id):
    return html.Div("Dropdown {} = {}".format(id["index"], value))


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

andre996 avatar Mar 21 '25 23:03 andre996

Is there a suggested workaround for this problem?

brucetrask avatar Nov 20 '25 18:11 brucetrask