Patch triggering button callback on unpatched child

I have a complex, dynamic list like structure with children that contain buttons. When patching a single child of that list, this causes a callback with a triggered_id of an unrelated button in another child that was not patched.

The following is a MRE with just three buttons where the click on button will update the displayed click count in the button’s name using a patch. See that confirming the update for any button immediately triggers the display_confirm callback and reopens the confirmation dialog for the first button “Foo”.

Is this really intended behaviour or should patch behave differently?

from dash import ALL, Dash, Input, Output, Patch, State, callback, ctx, dcc, html
from dash.exceptions import PreventUpdate

app = Dash()

button_names = ["Foo", "Bar", "Baz"]

app.layout = html.Div([
    dcc.ConfirmDialog(
        id="confirm-dialog",
        message="Update click count for button?",
    ),
    dcc.Store(
        id="last-button-clicked-id",
        data=None
    ),
    html.Div(id="button-container", children=[
        html.Button(f"{button_names[i]} 0", id={"type": "button", "index": i}) for i in range(len(button_names))
    ]),
])

@callback(
    Output("confirm-dialog", "displayed"),
    Output("confirm-dialog", "message"),
    Output("last-button-clicked-id", "data"),
    Input({"type": "button", "index": ALL}, "n_clicks"),
)
def display_confirm(n_clicks):
    """ Display the confirm dialog when a button is clicked and store the id of the clicked button."""
    if ctx.triggered_id:
        idx = ctx.triggered_id["index"]
        return True, f"Update click count for button {button_names[idx]}?", ctx.triggered_id
    else:
        raise PreventUpdate()

@callback(
    Output("button-container", "children"),
    Input("confirm-dialog", "submit_n_clicks"),
    State("last-button-clicked-id", "data"),
    State({"type": "button", "index": ALL}, "n_clicks")
)
def patch_button_click_count(submit_n_clicks, last_button_clicked_id, button_n_clicks):
    """Update the number of clicks on confirm by patching the relevant child of the button container."""
    if submit_n_clicks:
        idx = last_button_clicked_id["index"]
        patch = Patch()
        # update the button's name (however, even without the following line and an empty patch the strange behaviour persists)
        patch[idx]["props"]["children"] = f"{button_names[idx]} {button_n_clicks[idx]}"
        return patch
    else:
        raise PreventUpdate()

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

I’m not looking for a solution, that works around the issue by changing the button’s name directly instead of patching the containing div, because this is easy in this example, but would mean a lot effort in my real code. Right now I workaround the issue by storing the n_clicks and raising PreventUpdate in display_confirm when stored n_clicks equals the triggering n_clicks.

Something similar was asked before by @mingw64, but with no conclusive answer and not really focussing on the issue that a component that was not touched at all triggers a callback.

1 Like

Hello @datenzauberai,

Have you tried dash 3?

Yes. 3.0.0rc1 behaves the same.

Both 3.0.0rc1 and 2.18.2 even trigger the callback when the Patch is empty.

Is there a way that you can target with set_props?

This is always an issue with Patch or anything that causes adjustments on the parent object forcing a render on all the children components.

This then triggers callbacks.

You could potentially use the callback triggered context list must be singular?

I did not know about set_props. Great hint, thank you. I tried for the MRE and it works great.

set_props(last_button_clicked_id, {"children": f"{button_names[idx]} {button_n_clicks[idx]}"})

I fear it won’t work in my real code, because there I also use append and del for the Patch. I think del and append can not be easily translated to set_props.

Could you elaborate on that? I’m not sure I understand…

Here, try this:

from dash import ALL, Dash, Input, Output, Patch, State, callback, ctx, dcc, html
from dash.exceptions import PreventUpdate

app = Dash()

button_names = ["Foo", "Bar", "Baz"]

app.layout = html.Div([
    dcc.ConfirmDialog(
        id="confirm-dialog",
        message="Update click count for button?",
    ),
    dcc.Store(
        id="last-button-clicked-id",
        data=None
    ),
    html.Div(id="button-container", children=[
        html.Button(f"{button_names[i]} 0", id={"type": "button", "index": i}) for i in range(len(button_names))
    ]),
])

@callback(
    Output("confirm-dialog", "displayed"),
    Output("confirm-dialog", "message"),
    Output("last-button-clicked-id", "data"),
    Input({"type": "button", "index": ALL}, "n_clicks"),
)
def display_confirm(n_clicks):
    """ Display the confirm dialog when a button is clicked and store the id of the clicked button."""
    if ctx.triggered_id and len(ctx.triggered) == 1:
        idx = ctx.triggered_id["index"]
        return True, f"Update click count for button {button_names[idx]}?", ctx.triggered_id
    else:
        raise PreventUpdate()

@callback(
    Output("button-container", "children"),
    Input("confirm-dialog", "submit_n_clicks"),
    State("last-button-clicked-id", "data"),
    State({"type": "button", "index": ALL}, "n_clicks")
)
def patch_button_click_count(submit_n_clicks, last_button_clicked_id, button_n_clicks):
    """Update the number of clicks on confirm by patching the relevant child of the button container."""
    if submit_n_clicks:
        idx = last_button_clicked_id["index"]
        patch = Patch()
        # update the button's name (however, even without the following line and an empty patch the strange behaviour persists)
        patch[idx]["props"]["children"] = f"{button_names[idx]} {button_n_clicks[idx]}"
        return patch
    else:
        raise PreventUpdate()

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

This makes the assumption that no two triggers are going to update at the same exact time.


Taking this further, here is it adding more buttons and also deleting:

from dash import ALL, Dash, Input, Output, Patch, State, callback, ctx, dcc, html, no_update
from dash.exceptions import PreventUpdate

app = Dash()

button_names = ["Foo", "Bar", "Baz"]

app.layout = html.Div([
    dcc.ConfirmDialog(
        id="confirm-dialog",
        message="Update click count for button?",
    ),
    dcc.Store(
        id="last-button-clicked-id",
        data=None
    ),
    html.Button(id='add_more', children='I Can Addz M0re?', n_clicks=2),
    html.Button(id='too_many_buttons', children='TOO MANY BUTTONS'),
    html.Div(id="button-container", children=[
        html.Button(f"{button_names[i]} 0", id={"type": "button", "index": i}) for i in range(len(button_names))
    ]),

])

@callback(
    Output("confirm-dialog", "displayed"),
    Output("confirm-dialog", "message"),
    Output("last-button-clicked-id", "data"),
    Input({"type": "button", "index": ALL}, "n_clicks"),
)
def display_confirm(n_clicks):
    """ Display the confirm dialog when a button is clicked and store the id of the clicked button."""
    if ctx.triggered_id and len(ctx.triggered) == 1:
        idx = ctx.triggered_id["index"]
        name = button_names[idx] if idx < len(button_names) else 'new button - ' + str(idx)
        return True, f"Update click count for button {name}?", ctx.triggered_id
    else:
        raise PreventUpdate()

@callback(
    Output("button-container", "children"),
    Input("confirm-dialog", "submit_n_clicks"),
    State("last-button-clicked-id", "data"),
    State({"type": "button", "index": ALL}, "n_clicks"),
    State({"type": "button", "index": ALL}, "id")
)
def patch_button_click_count(submit_n_clicks, last_button_clicked_id, button_n_clicks, ids):
    """Update the number of clicks on confirm by patching the relevant child of the button container."""
    if submit_n_clicks:
        idx = last_button_clicked_id["index"]
        patch = Patch()
        name = button_names[idx] if idx < len(button_names) else 'new button - ' + str(idx)
        clicks = ''
        newIndex = 0
        for x in range(len(ids)):
            if idx == ids[x]['index']:
                clicks = button_n_clicks[x]
                newIndex = x
                break
        # update the button's name (however, even without the following line and an empty patch the strange behaviour persists)
        patch[newIndex]["props"]["children"] = f"{name} {clicks}"
        return patch
    else:
        raise PreventUpdate()

@callback(
    Output("button-container", "children", allow_duplicate=True),
    Input("add_more", "n_clicks"),
    prevent_initial_call=True
)
def add_buttonz(n):
    """Update the number of clicks on confirm by patching the relevant child of the button container."""
    if n:
        children = Patch()
        children.append(html.Button(f"new buttonz - {n} 0", id={"type": "button", "index": n}))
        return children
    return no_update

@callback(
    Output("button-container", "children", allow_duplicate=True),
    Input("too_many_buttons", "n_clicks"),
    prevent_initial_call=True
)
def delete_buttonz(n):
    """Update the number of clicks on confirm by patching the relevant child of the button container."""
    if n:
        children = Patch()
        del children[0]
        return children
    return no_update

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

This will keep it from triggering over and over again when you have more than 1 component. With just 1 component, it is more tricky.

1 Like

Thanks a lot @jinnyzor I did not notice that ctx.triggered would show that all buttons actually triggered the callback collectively and ctx.triggered_id just contains the id of the first one. Might be much easier than storing the n_clicks. Have to check the situation with a single component though.

A part from working around the accidentally triggered callback: Is the triggering of the callback really as expected? I would have thought, that handling the patch should either not trigger a callback at all or at least only for patched components.

It’s hard to account for, looking at the code it seems it’s treated like an initial callback. Since the prop is refreshing inside of the parent.

I tried to find in the functions where would be the proper place to test this, but I’m not sure there really is a way to make sure to no trigger in such instances.

Maybe @Philippe might know.