Handling false callback triggers with multiple dynamic elements

Hey all,
I went a bit wild with creating pattern matching dynamic elements.

My UI has the following:

  • Multiple dbc.Tabs
  • Each tabs has multiple dbc.Tables with 10-500 cells each
  • Most of the cells are html.As with a pattern matching id, identifying the cell type (column) and a certain other id (row).
  • I have a callback that listens to clicks on these html.As. Based on the type and id it generates a modal showing some plotly chart unique for this type and id combination.

So far sounds ok I hope. Where this gets complicated:

  • When the user plays with other elements in the GUI the tables are regenerated. Some columns and rows appear and disappear, and some cells stop being html.As and become simple (“NA”) text, or the other way around.
  • The tabs may show the same elements. So tab 1 may show an html.A with the same type and id as shown in tab 2.

Trivially, I started with using this input:
Input({‘type’: MY_TYPE, ‘cell_type’: ALL, ‘cell_id’: ALL, ‘n_clicks’)
However, this caused many false triggers whenever cells appeared, disappeared or reappeared. In my case - false trigger = popup appearing unnecessarily. Not a good behavior.

Anyway, after a lot of tracing of each and every such case, I managed to code the below monstrosity that avoids 99.9% of the false triggers.

Clearly I’m doing something wrong… Beyond a bunch of ifs, I resorted to storing the n_clicks array in a dcc.store… Any ideas?

@callback(
	...outputs...
    Output(STORE_MODAL_NCLICKS_ID, 'data'),
	Input({'type': MY_TYPE, 'cell_type': ALL, 'cell_id': ALL, 'n_clicks')
    State(STORE_MODAL_NCLICKS_ID, 'data'),
    ...states...
    prevent_initial_call=True
)
def show_popup_chart(n_clicks, n_clicks_store, ...):
	empty_return = X * dash.no_update, n_clicks
	trigger = ctx.triggered_id
	
    if trigger is None:  # triggers happen on cell DISappearance
        return empty_return
    # if this is not a normal click, then avoid the value comparison false alarm case.
    # this is needed since there are cases where dash.callback_context.triggered is 0 even though there was a click.
    # happens since an element of the same name is sometimes created in both tabs, which messes stuff up
    skip_val_compare = False
    if (n_clicks is not None and n_clicks_store is not None and len(n_clicks) == len(n_clicks_store) and
            sum(a - b == 1 for a, b in zip(n_clicks, n_clicks_store) if a is not None and b is not None) == 1):
        skip_val_compare = True
    # avoids trigger on initial creation of tables and some actions which create new elements in n_clicks
    if all(d['value'] == 0 or d['value'] is None for d in dash.callback_context.triggered) and not skip_val_compare:
        return empty_return
    # X element movements cause a trigger but don't change n_clicks or set some (not all) of n_clicks elements to 0
    # except when new elements are added to n_clicks, then we still want a trigger. this condition should be last
    if (n_clicks_store is not None
            and all(a == b or (a == 0 and b != 0) for a, b in zip(n_clicks, n_clicks_store))
            and len(n_clicks) <= len(n_clicks_store)):
        return empty_return

Hey @mingw64,

You are creating content dynamically, right? Everytime you add a new component via callback, the corresponding callbacks to the added components are triggered. This applies for example, if you are using pattern matching callbacks.

1 Like

Thank you @AIMPED.

Regarding this part in the pattern matching case:

if not any(clicks):
        raise PreventUpdate

Won’t this stop working once a cell was clicked once? Its n_clicks will go to 1, and any future clicks will fail the if and continue with function execution.

After posting I dove into the topic again and implemented something simpler, which at least from initial testing seems to work:

    triggered_nclicks = get_value_from_dash_inputs(ctx.inputs, ctx.triggered_id)
    if triggered_nclicks in [0, None]:
        raise dash.exceptions.PreventUpdate

This check the n_clicks value of the specific input that triggered. From what I see even if 100 cells appear simultaneously, only one causes a trigger and after ignoring it there aren’t any further ones.

With:

def get_value_from_dash_inputs(inputs, triggered_id):
    if triggered_id is None or not inputs:
        return None
    key = f'{json.dumps(triggered_id, separators=(",", ":"))}.n_clicks'
    return inputs.get(key)

The get_value_from_dash_inputs function was needed because ctx.inputs contains strings, while ctx.triggered_id is a dict. It formats the dict in the same format as the dicts, allowing accessing ctx.inputs.

Yes. The above is just for preventing the callback from execution when the components are added to the layout.

In general these kind of behavior is pretty difficult to debug without seeing all callbacks of the app.

And on general, it should not be necessary to avoid the run of the triggered callback with “complicated” if/then cases.

Got it. In that case this doesn’t work for me since I have components appearing in multiple stages in the same n_clicks array. It can start with 30 components for example, some of them can be clicked (n_clicks goes to 1) and then more components appear.