Best way to update a single component from multiple pattern matching components

I have a bunch of buttons around my app that i want to toggle an accordion to be open. There are multiple buttons but one accordion so I am getting the error of

In the callback for output(s):
  {"id":"accordion","idx":0,"prop":"state"}.data
Input 0 ({"index":MATCH,"type":"btn-click"}.n_clicks)
has MATCH or ALLSMALLER on key(s) index
where Output 0 ({"id":"accordion","idx":0,"prop":"state"}.data)
does not have a MATCH wildcard. Inputs and State do not
need every MATCH from the Output(s), but they cannot have
extras beyond the Output(s).

Whats the best hack to get this to work. Here is some base code

installed the following

  • dash
  • dash-extensions
  • dash-mantine-components
import dash_mantine_components as dmc
from dash import State, MATCH, ctx
from dash.exceptions import PreventUpdate
from dash_extensions.enrich import Output, DashProxy, Input, MultiplexerTransform, html

app = DashProxy(transforms=[MultiplexerTransform()])
app.layout = html.Div(children=[
    dmc.Button("toggle", id="toggle", n_clicks=0),
    html.Br(),
    *[dmc.Button(f"Button {i}" , id={"type": "btn-click", "index": i}) for i in range(5)],
    dmc.Accordion(
        children=[
            dmc.AccordionItem("Data 1", label="One" ),            
           dmc.AccordionItem("Data 2",  label="Two"),
        ],
        id="accordion"
    )])

@app.callback(
    Output("accordion", "state"),
    Input({"type": "btn-click", "index": MATCH}, "n_clicks"),
    prevent_initial_call=True)
def toggle_accordion(clicks):
    if clicks == 0:
        raise PreventUpdate
    return {"0": False, "1": True}

@app.callback(
    Output("accordion", "state"),
    Input("toggle", "n_clicks"),
    State("accordion", "state"),
    prevent_initial_call=True
)
def toggle(clicks, state):
    state = state or {"0": False, "1": False}
    if clicks == 0:
        raise PreventUpdate
    return {"0": False, "1": not state["1"]}

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

Hello @dales,

I think you should try to use ALL instead of match, match refers to a specific component that you want to update.

@jinnyzor thanks, I totally blanked there and got my head stuck on using MATCH

1 Like

@jinnyzor I recall now why I was focused on MATCH and not ALL. The list of buttons can be altered in my UI dynamically. And when I add a new button, it automatically calls the callbacks since the components get re-rendered (if I understand it correctly). the n_clicks gets fired and I cannot see how I can detect that it was a false call.

import dash_mantine_components as dmc
import flask
from dash import State, ALL, ctx
from dash.exceptions import PreventUpdate
from dash_extensions.enrich import Output, DashProxy, Input, MultiplexerTransform, html
f_app = flask.Flask(__name__)

NUM_ITEMS = 5

def new_box(index):
    return html.Div(children=dmc.Button(f"Button {index}", n_clicks=0, id={"type": "btn-click", "index": index}), style={"padding": "5px", "border": "1px solid red"})

def new_label(index):
    return dmc.Badge(f"Button {index} clicked", id={"type": "text-label", "index": index}, style={"display": "none"})


app = DashProxy(transforms=[MultiplexerTransform()], server=f_app)
app.layout = html.Div(children=[
    dmc.Button("Add", id="add", n_clicks=0),
    html.Div([new_label(i) for i in range(NUM_ITEMS)], id="labels"),
    html.Div(children=[new_box(i) for i in range(NUM_ITEMS)], id="buttons", style={"display": "flex", "gap": "5px", "margin": "5px 0"}),
    dmc.Accordion(
        children=[
            dmc.AccordionItem("Data 1", label="One" ),
            dmc.AccordionItem("Data 2",  label="Two"),
        ],
        state={"0":False, "1": False},
        id="accordion"
    )])

@app.callback(
    Output("accordion", "state"),
    Output({"type": "text-label", "index": ALL}, "style"),
    Input({"type": "btn-click", "index": ALL}, "n_clicks"),
    State("accordion", "state"),
    prevent_initial_call=True)
def toggle_accordion(clicks, state):
    button = ctx.triggered_id
    return {"0": False, "1": not state["1"]}, [{"display": "inline-block" if i == button["index"] else "none"} for i in range(len(clicks))]

@app.callback(
    Output("accordion", "state"),
    Output("buttons", "children"),
    Output("labels", "children"),
    Input("add", "n_clicks"),
    State("accordion", "state"),
    State("buttons", "children"),
    State("labels", "children"),
    prevent_initial_call=True
)
def add_new(clicks, state, buttons, labels):
    new_index = len(buttons)
    if clicks == 0:
        raise PreventUpdate
    buttons.append(new_box(new_index))
    labels.append(new_label(new_index))
    state["1"] = True
    return state, buttons, labels

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

Try adding some ctx and args to keep it from triggering unless the n value is above 0.

—-
You might need to pull in another state that is the id of the index match, unless your buttons are just numbered numerically, in which case you can pull only the edited buttons value from the list.

the triggered is a list of clicks, so that doesnt really help. I cant tell which one was triggered.
What I did now was merge the add and the click callbacks into one and use ctx to determine which was triggered.
This works but its messy as I need to add additional states and outputs that arent needed for the separate cases

This is similar to the example here - Pattern-match

import dash_mantine_components as dmc
import flask
from dash import State, ALL, ctx, no_update
from dash.exceptions import PreventUpdate
from dash_extensions.enrich import Output, DashProxy, Input, MultiplexerTransform, html
f_app = flask.Flask(__name__)

NUM_ITEMS = 5

def new_box(index):
    return html.Div(children=dmc.Button(f"Button {index}", n_clicks=0, id={"type": "btn-click", "index": index}), style={"padding": "5px", "border": "1px solid red"})

def new_label(index):
    return dmc.Badge(f"Button {index} clicked", id={"type": "text-label", "index": index}, style={"display": "none"})


app = DashProxy(transforms=[MultiplexerTransform()], server=f_app)
app.layout = html.Div(children=[
    dmc.Button("Add", id="add", n_clicks=0),
    html.Div([new_label(i) for i in range(NUM_ITEMS)], id="labels"),
    html.Div(children=[new_box(i) for i in range(NUM_ITEMS)], id="buttons", style={"display": "flex", "gap": "5px", "margin": "5px 0"}),
    dmc.Accordion(
        children=[
            dmc.AccordionItem("Data 1", label="One" ),
            dmc.AccordionItem("Data 2",  label="Two"),
        ],
        state={"0":False, "1": False},
        id="accordion"
    )])

@app.callback(
    Output("accordion", "state"),
    Output({"type": "text-label", "index": ALL}, "style"),
    Output("buttons", "children"),
    Output("labels", "children"),
    Input({"type": "btn-click", "index": ALL}, "n_clicks"),
    Input("add", "n_clicks"),
    State("accordion", "state"),
    State("buttons", "children"),
    State("labels", "children"),
    prevent_initial_call=True)
def toggle_accordion(button_clicked, add_clicked, state, buttons, labels):
    if ctx.triggered_id == "add":
        new_index = len(buttons)
        buttons.append(new_box(new_index))
        labels.append(new_label(new_index))
        state["1"] = True
        return state, [{}] * (len(buttons) - 1), buttons, labels
    else:
        button = ctx.triggered_id
        state["1"] = not state["1"]
        styles = [{"display": "inline-block" if i == button["index"] else "none"} for i in range(len(button_clicked))]
        return state, styles, no_update, no_update


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

It looks like your buttons are based upon the number of children, sweet.

Since this is the case, you should be able to use:


def toggle_accordion(clicks, state):
     n = ctx.triggered_id.index
     if clicks[n] > 0:
           return {0: False, 1: True}
     return dash.no_update

Or something similar.

I dont think this will work. If i click on the first button it gets n_clicks = 1, and then later if this callback gets triggered from adding a new button, then I have no way of knowing that it should not be called. n will have value of 0, and clicks[n] will have value of 1 from previous click

All the buttons are triggered when you add one, or just the one?

Clicking the add button adds a new html Element to the buttons children. This triggers the n_clicks of this new component

since it is an ALL, I get an array of all of the clicks.

When I click add once, the value of Input({"type": "btn-click", "index": ALL}, "n_clicks"), is [0. 0, 0, 0, 0, 0]

and ctx.triggered_id["index"] is 0. When I click on this button, the value is [1, 0, 0, 0, 0, 0] and the rest is the same.

The index of the triggered id is used to pull the desired target from the list of values.

this behavior seems to be different from desired behavior when I add an element to the list of children and catch the clicks with ALL. When I click a button then you are correct that triggered_id is the id of the element from the ALL that caused the Input to be triggered.

But in my case, just adding a new element to the list triggers this. It kind of feels like prevent_initial_call is not being respected here, or it behaves differently. The callback gets triggered even before any clicks happen, which doesnt make sense if all the n_click values are 0

When you add a button, and it is expanded, your desired result is to stay expanded?

this example does not cover my entire use case and it is just a subset of the problem. In theory I need the badge to only change when I click on a button item (not the add button).

When I click the button, I want to open the accordion and the correct content to be inside. I fixed the example to better represent this

import dash_mantine_components as dmc
import flask
from dash import State, ALL, ctx
from dash.exceptions import PreventUpdate
from dash_extensions.enrich import Output, DashProxy, Input, MultiplexerTransform, html
f_app = flask.Flask(__name__)

NUM_ITEMS = 5

def new_box(index):
    return html.Div(children=dmc.Button(f"Toggle {index}", n_clicks=0, id={"type": "btn-click", "index": index}), style={"padding": "25px", "border": "1px solid #228be7"})

def new_label(index):
    return dmc.Badge(f"Toggle {index} open", id={"type": "text-label", "index": index}, style={"display": "none"})


app = DashProxy(transforms=[MultiplexerTransform()], server=f_app)
app.layout = html.Div(children=[
    dmc.Button("Add Toggle", id="add", n_clicks=0),
    html.Div(children=[new_box(i) for i in range(NUM_ITEMS)], id="buttons", style={"display": "flex", "gap": "5px", "margin": "5px 0"}),
    dmc.Accordion(
        children=[
            dmc.AccordionItem("Nothing intersting", label="First" ),
            dmc.AccordionItem(html.Div([new_label(i) for i in range(NUM_ITEMS)], id="labels"),  label="Toggles"),
        ],
        state={"0":False, "1": False},
        id="accordion"
    )])

@app.callback(
    Output("accordion", "state"),
    Output({"type": "text-label", "index": ALL}, "style"),
    Input({"type": "btn-click", "index": ALL}, "n_clicks"),
    State("accordion", "state"),
    prevent_initial_call=True)
def toggle_accordion(clicks, state):
    button = ctx.triggered_id
    if clicks[button["index"]] == 0:
        raise PreventUpdate
    state["1"] = True
    return state, [{"display": "inline-block" if i == button["index"] else "none"} for i in range(len(clicks))]

@app.callback(
    Output("accordion", "state"),
    Output("buttons", "children"),
    Output("labels", "children"),
    Input("add", "n_clicks"),
    State("accordion", "state"),
    State("buttons", "children"),
    State("labels", "children"),
    prevent_initial_call=True
)
def add_new(clicks, state, buttons, labels):
    new_index = len(buttons)
    if clicks == 0:
        raise PreventUpdate
    buttons.append(new_box(new_index))
    labels.append(new_label(new_index))
    state["1"] = True
    return state, buttons, labels

if __name__ == '__main__':
    app.run_server(debug=True)
import dash_mantine_components as dmc
import flask
from dash import State, ALL, ctx
from dash.exceptions import PreventUpdate
from dash_extensions.enrich import Output, DashProxy, Input, MultiplexerTransform, html
import dash
f_app = flask.Flask(__name__)

NUM_ITEMS = 5

def new_box(index):
    return html.Div(children=dmc.Button(f"Button {index}", n_clicks=0, id={"type": "btn-click", "index": index}), style={"padding": "5px", "border": "1px solid red"})

def new_label(index):
    return dmc.Badge(f"Button {index} clicked", id={"type": "text-label", "index": index}, style={"display": "none"})


app = DashProxy(transforms=[MultiplexerTransform()], server=f_app)
app.layout = html.Div(children=[
    dmc.Button("Add", id="add", n_clicks=0),
    html.Div([new_label(i) for i in range(NUM_ITEMS)], id="labels"),
    html.Div(children=[new_box(i) for i in range(NUM_ITEMS)], id="buttons", style={"display": "flex", "gap": "5px", "margin": "5px 0"}),
    dmc.Accordion(
        children=[
            dmc.AccordionItem("Data 1", label="One" ),
            dmc.AccordionItem("Data 2",  label="Two"),
        ],
        state={"0":False, "1": False},
        id="accordion"
    )])

@app.callback(
    Output("accordion", "state"),
    Output({"type": "text-label", "index": ALL}, "style"),
    Input({"type": "btn-click", "index": ALL}, "n_clicks"),
    State("accordion", "state"),
    prevent_initial_call=True)
def toggle_accordion(clicks, state):
    button = ctx.triggered_id
    n = ctx.triggered_id.index
    if clicks[n] > 0:
        return {"0": False, "1": not state["1"]}, [{"display": "inline-block" if i == button["index"] else "none"} for i in range(len(clicks))]
    return dash.no_update

@app.callback(
    Output("accordion", "state"),
    Output("buttons", "children"),
    Output("labels", "children"),
    Input("add", "n_clicks"),
    State("accordion", "state"),
    State("buttons", "children"),
    State("labels", "children"),
    prevent_initial_call=True
)
def add_new(clicks, state, buttons, labels):
    new_index = len(buttons)
    if clicks == 0:
        raise PreventUpdate
    buttons.append(new_box(new_index))
    labels.append(new_label(new_index))
    state["1"] = True
    return state, buttons, labels

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

Results in:

Screen Recording 2022-11-23 at 6.14.14 AM

if i can see from there correctly, clicking button 1, then clicking buitton 2 sets the badge to “Button 2 clicked” but when you click Add again, it reverts back to 0, since clicks[n] > 0 is true, even though it was not just clicked

Ok, I think I got what you are after. I had to use a dcc.Store:

import dash_mantine_components as dmc
import flask
from dash import State, ALL, ctx, dcc
from dash.exceptions import PreventUpdate
from dash_extensions.enrich import Output, DashProxy, Input, MultiplexerTransform, html
import dash, json
f_app = flask.Flask(__name__)

NUM_ITEMS = 5

def new_box(index):
    return html.Div(children=dmc.Button(f"Button {index}", n_clicks=0, id={"type": "btn-click", "index": index}), style={"padding": "5px", "border": "1px solid red"})

def new_label(index):
    return dmc.Badge(f"Button {index} clicked", id={"type": "text-label", "index": index}, style={"display": "none"})


app = DashProxy(transforms=[MultiplexerTransform()], server=f_app)
app.layout = html.Div(children=[
    dmc.Button("Add", id="add", n_clicks=0),
    dcc.Store(id='btnStore', storage_type='memory', data=''),
    html.Div([new_label(i) for i in range(NUM_ITEMS)], id="labels"),
    html.Div(children=[new_box(i) for i in range(NUM_ITEMS)], id="buttons", style={"display": "flex", "gap": "5px", "margin": "5px 0"}),
    dmc.Accordion(
        children=[
            dmc.AccordionItem("Data 1", label="One" ),
            dmc.AccordionItem("Data 2",  label="Two"),
        ],
        state={"0":False, "1": False},
        id="accordion"
    )])

@app.callback(
    Output("accordion", "state"),
    Output({"type": "text-label", "index": ALL}, "style"),
    Output('btnStore', "data"),
    Input({"type": "btn-click", "index": ALL}, "n_clicks"),
    State("accordion", "state"),
    State('btnStore', "data"),
    prevent_initial_call=True)
def toggle_accordion(clicks, state, oldClick):
    button = ctx.triggered_id
    n = ctx.triggered_id.index
    newClick = clicks[0]
    if newClick != oldClick or button.index != 0:
        oldClick = newClick
        return {"0": False, "1": not state["1"]}, [{"display": "inline-block" if i == button["index"] else "none"} for i in range(len(clicks))], oldClick
    return dash.no_update, [dash.no_update]*(len(clicks)), dash.no_update

@app.callback(
    Output("accordion", "state"),
    Output("buttons", "children"),
    Output("labels", "children"),
    Input("add", "n_clicks"),
    State("accordion", "state"),
    State("buttons", "children"),
    State("labels", "children"),
    prevent_initial_call=True
)
def add_new(clicks, state, buttons, labels):
    new_index = len(buttons)
    if clicks == 0:
        raise PreventUpdate
    buttons.append(new_box(new_index))
    labels.append(new_label(new_index))
    state["1"] = True
    return state, buttons, labels

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

Results:

Screen Recording 2022-11-23 at 6.39.49 AM

I was hoping for a simpler solution that I was missing. I also thought to store the latest n_clicks_timestamp and check relative to that if there was really a click or not

Even though I understand why this is happening I kind of expect it to not trigger since all of the n_clicks are 0 which means none of them were clicked really

You are technically touching all of the buttons by adjusting the children, even if it is just adding the same props back. One of the fun things of React I guess.

I thought maybe the prevent_initial_call would handle this case for me :slight_smile:
Thanks for your ideas above

1 Like