Modularization of Complex Applications: Multiple Callbacks vs Big Single Callback

During the refactoring of an application I came across one problem where I’m interested in the opinion of others and pattern you used to solve that.

Imagine an application where you have a modal dialog, that contains an input select cascade with 3 levels: category, subcategory, item.

The most intuitive way for the callback would be to have four different callbacks:

  1. handle_open: populates options for category input, reset subcategory input and item input
  2. handle_category_change: populate options for subcategory, reset item input
  3. handle_subcategory_change: populate options for item input
  4. handle_item_change: display item information

Pro: clear separation of concerns
Con: triggers a chain of callbacks (depending on timings some callbacks even more than once)

For smaller use-cases a solid option is to have a big single callback that covers all relevant inputs/outputs. The different updates can be done depending on the triggering input (ctx.triggered_id). Dash automatically prevents retriggering the callback based on its own outputs.

Pro: triggers only once
Con: breaks separation of concerns

While for the small example the single callback approach seems perfectly fine to me, I have a harder time to get this properly designed in an application of higher size and complexity where Abstract AIO components cover basic functionality that is extended by subclasses and a lot of input controls are involved.

Patterns that you found useful in similar situations would be greatly appreciated!

Hello @datenzauberai

For this specific use-case, I’d use:

As far as other things, I like to split things out as needed. Beyond 100 lines it gets hard to visually follow when you come back to the code.

1 Like

That is a good question @datenzauberai

I remember having all functionalities cramped into one huge callback because of limitations in dash prior to v2.9.

Once that limitation fell, i switched to modular callbacks, separated by concerns as you call it. I never looked back. I forced myself to think harder and write as much client-side callbacks as possible to minimize traffic and latency.

When in doubt, always follow @jinnyzor advice :wink:

1 Like

That’s a nice suggestion! My use-case is not based on static schema so pydantic is not a good option, but I guess the “Dash Pydantic Form” library itself had to solve similar issues. Do you have any clue on how these things are handled inside the library itself?

How dynamic are we talking about? You can dynamically use create_model and you can retrieve a cached model in the event that a different worker is responding to your request. :slight_smile:

Its an AIO on steroids pretty much.

It’s not data entry into a form (where pydf seems to shine), It’s doing calculations on the server side based on the parameters set and let the user view the resulting data from different perspectives.

You can set pydantic-form for read only, which would still allow other functions, like conditionally displaying data, etc.

Another thing that comes to mind is using something like AG Grid.

Is it possible to get a small example to look at it? :slight_smile:

It’s basically about 10 modal dialogs that have some common inputs (text inputs, tags inputs, select inputs, custom AIOs), common functionality (save, cancel, undo/redo using a custom MementoAIO), but also dialog specific inputs, different aggrid views that are rendered depending on the input and settings. Common functionality in violet.

One thing that works for me is to break the trigger chain by using a wrapper that only emits when the value really changed. The input can then be set without triggering subsequent callbacks, by setting both the input value and the last emitted value. This still triggers the client-side callback, but won’t propagate to server-side callbacks.

However, wrapping all the relevant inputs with such a wrapper bloats the code base, hence, why I’m interested in how others approach this “update but don’t trigger” problem.

class DistinctUntilChangedAIO(dmc.Box):
    @classmethod
    def id_input(cls, aio_id: str | _Wildcard):
        return {"component": "InputValue", "aio_id": aio_id}
    @classmethod
    def id_last(cls, aio_id: str | _Wildcard):
        return {"component": "LastEmittedValue", "aio_id": aio_id}
    @classmethod
    def id_emitted(cls, aio_id: str | _Wildcard):
        return {"component": "EmittedValue", "aio_id": aio_id}
    
    def __init__(
        self,
        aio_id: str,
        input_component,
    ):
        emitted_store = dcc.Store(id=self.id_emitted(aio_id=aio_id))
        last_store = dcc.Store(id=self.id_last(aio_id=aio_id))
        input_component.id = self.id_input(aio_id=aio_id)
        super().__init__(children=[dmc.Box([emitted_store, last_store], display="none"), input_component])

app.clientside_callback(
    """
    function(input_value, last_emitted_value) {        
        if (input_value === undefined || input_value === last_emitted_value) {
            // Don't emit if undefined (not initialized) or if value hasn't changed
            return [window.dash_clientside.no_update, window.dash_clientside.no_update];
        }
        // Value changed, emit it
        return [input_value, input_value];
    }
    """,
    Output(DistinctUntilChangedAIO.id_emitted(MATCH), "data"),
    Output(DistinctUntilChangedAIO.id_last(MATCH), "data"),
    Input(DistinctUntilChangedAIO.id_input(MATCH), "value"),
    State(DistinctUntilChangedAIO.id_last(MATCH), "data"),
    prevent_initial_call=True,
)

The top portion of your form is a good spot for pydantic form, as all the inputs would be read in a single dictionary just like the wrapper you are talking about.

Here is an example flow based upon the image you gave:

from dash import Dash, dcc, html, Input, Output, set_props, ALL, MATCH, ctx, no_update
from dash_pydantic_form import ModelForm
import dash_mantine_components as dmc
import dash_ag_grid as dag
import pandas as pd

# Initialize Dash app
app = Dash(__name__, external_stylesheets=dmc.styles.ALL)

# Sample data for the table
data = {
    "Header A": ["Cell A"] * 6,
    "Header B": ["Cell B"] * 6,
    "Header C": ["Cell C"] * 6,
}
df = pd.DataFrame(data)

# Pydantic model for the form
from pydantic import BaseModel, Field, create_model

# Define fields dynamically
fields = {
    "general_input": (str, Field(..., title="General Input")),
    "specific_input": (str, Field(..., title="Specific Input")),
    "general_custom_aio": (str, Field(..., title="General Custom AIO")),
    "additional_settings1": (bool, Field(False, title="Additional Settings")),
    "additional_setting1_details": (dict, Field(default_factory=dict,
                                               title="Additional Setting 1 Details",
                                               repr_kwargs={"visible": [("additional_settings1", '==', True)]})),
    "grid_settings_1": (bool, Field(False, title="Grid Settings 1")),
    "grid_settings_2": (bool, Field(False, title="Grid Settings 2")),
}

# Create the model dynamically
def create_grid_form(fields):
    return create_model('FormModel', **fields)

def action_button(**kwargs):
    _id = kwargs.pop('id')
    return dmc.Button(id={'index': _id, 'type': 'button_function'}, **kwargs)

def undo():
    print("Undo action triggered")

def redo():
    print("Redo action triggered")

def action1():
    set_props('dialog-title', {'children': "Action 1 Title"})
    print("Action 1 triggered")

def action2():
    print("Action 2 triggered")

def action3():
    set_props('dialog-title', {'children': f"{ctx.triggered_id.index} Title"})
    print("Action 3 triggered")

action_map = {
    "undo-btn": undo,
    "redo-btn": redo,
    "action1-btn": action1,
    "action2-btn": action2,
    "action3-btn": action3,
}

# Layout
app.layout = dmc.MantineProvider([
    html.H2("Dialog Title", id="dialog-title"),
    ModelForm(
        create_grid_form(fields),
        aio_id="pydantic-form",
        form_id="form1"
    ),
    html.Hr(),
    html.Div([
        action_button(children="Undo", id="undo-btn"),
        action_button(children="Redo", id="redo-btn"),
        action_button(children="Action 1", id="action1-btn"),
        action_button(children="Action 2", id="action2-btn"),
        action_button(children="Action 3", id="action3-btn"),
    ]),
    html.Div([
        dcc.Tabs(id="tabs", value="tab-1", children=[
            dcc.Tab(label="Grid View 1", value="tab-1"),
            dcc.Tab(label="Grid View 2", value="tab-2"),
            dcc.Tab(label="Grid View 3", value="tab-3"),
            dcc.Tab(label="Grid View 4", value="tab-4"),
        ]),
        html.Div(id="tabs-content")
    ]),
    html.Div([
        html.Button("Cancel", id="cancel-btn"),
        html.Button("Save", id="save-btn", style={"background-color": "blue", "color": "white"}),
    ]),
])

@app.callback(
    Output("tabs-content", "children"),
    Input("tabs", "value")
)
def render_tab_content(tab_value):
    # Render the table for the tabs
    return dag.AgGrid(
        id=f"grid-{tab_value}",
        columnDefs=[{"headerName": col, "field": col, 'flex': 1} for col in df.columns],
        rowData=df.to_dict("records"),
        defaultColDef={"editable": True, "sortable": True},
        dashGridOptions={"animateRows": False}
    )

@app.callback(
    Output("dialog-title", "children"),
    Input({'type': 'button_function', 'index': ALL}, 'n_clicks'),
)
def handle_action(n_clicks):
    if not n_clicks or all(click is None for click in n_clicks):
        return "Dialog Title"

    triggered_id = ctx.triggered_id
    if triggered_id and triggered_id['type'] == 'button_function':
        action_func = action_map.get(triggered_id['index'])
        if action_func:
            action_func()

    return no_update

@app.callback(
    Input(ModelForm.ids.main('pydantic-form', 'form1'), "data")
)
def on_form_change(data):
    print("Form data changed:", data)

# Run the Dash app
if __name__ == "__main__":
    app.run(debug=True)

It’s not pretty, but its more about getting an example of the functionality

1 Like

Thanks a lot for the effort @jinnyzor !

However, I still don’t get how this solves the initial problem of the callback chain. User executes action 1, which calls a service and updates general input, which in turn leads to a lookup in the database that updates options and selected value of custom aio, which leads to …