Quantcast
Channel: 🎛️ Dash - Plotly Community Forum
Viewing all articles
Browse latest Browse all 6271

Duplicate Callback Outputs - Solution & API Discussion

$
0
0

:wave: Hello everyone –

In yesterday’s Dash 2.0 webinar, there were a surprising (to me!) number of questions asking if we “allow multiple callbacks to update the same output.”

I wanted to kick off a discussion of this topic.

How to do this today

Here is the canonical working example that allows you to update an output. This works! It allows you to target a single input from multiple inputs.

from dash import Dash, callback, Input, Output, State, callback_context, html, dcc
import plotly.express as px
import plotly.graph_objects as go

app = Dash(__name__)

app.layout = html.Div([
    html.Button('Draw Graph', id='draw'),
    html.Button('Reset Graph', id='reset'),
    dcc.Graph(id='graph')
])

@app.callback(
    Output('graph', 'figure'),
    Input('reset', 'n_clicks'),
    Input('draw', 'n_clicks'),
    prevent_initial_call=True
)
def update_graph(b1, b2):
    triggered_id = callback_context.triggered[0]['prop_id']
    if 'reset.n_clicks' == triggered_id:
         return reset_graph()
    elif 'draw.n_clicks' == triggered_id:
         return draw_graph()

def draw_graph():
    df = px.data.iris()
    return px.scatter(df, x=df.columns[0], y=df.columns[1])

def reset_graph():
    return go.Figure()

app.run_server(debug=True)

Multiple Callbacks Targeting Same Output - This Syntax Doesn’t Work

Here is what I believe users expect to be able to do:

from dash import Dash, callback, Input, Output, State, callback_context, html, dcc
import plotly.express as px
import plotly.graph_objects as go

app = Dash(__name__)

app.layout = html.Div([
    html.Button('Draw Graph', id='draw'),
    html.Button('Reset Graph', id='reset'),
    dcc.Graph(id='graph')
])


@app.callback(
    Output('graph', 'figure'),
    Input('draw', 'n_clicks'),
    prevent_initial_call=True
)
def draw_graph(_):
    df = px.data.iris()
    return px.scatter(df, x=df.columns[0], y=df.columns[1])

@app.callback(
    Output('graph', 'figure'),
    Input('reset', 'n_clicks'),
    prevent_initial_call=True
)
def reset_graph(_):
    return go.Figure()


app.run_server(debug=True)

If you run this example, you’ll receive this error message:

Duplicate callback outputs

In the callback for output(s):
  graph.figure
Output 0 (graph.figure) is already in use.
Any given output can only have one callback that sets it.
To resolve this situation, try combining these into
one callback function, distinguishing the trigger
by using `dash.callback_context` if necessary.

Trade-Offs

We could support the unsupported example as an official part of the Dash API. Community member’s @Emil 's excellent dash-extensions library has MultiplexerTransform which implements this with a dcc.Store technique. I suspect that we if we supported this in Dash we would try to wire in the triggered option above into the dash library so that no extra requests are made and no dcc.Store's are necessary.

That being said, there are some API challenges and trade-offs to supporting multiple callbacks targeting the same output:

  • Which callback to fire at start?

    Do you fire all of the callbacks? Or none of them? This behavior is ambiguous and In the face of ambiguity, [we should] refuse the temptation to guess.

    In the triggered example, you can have a final else statement that handles this case.
    In order for this to be unambiguous, we’d need the user to set prevent_initial_call for N-1 of their callbacks. At that point, writing Duplicate Callback Outputs would probably be the same lines of code as using triggered and multiple functions. If the user didn’t specify prevent_initial_call for N-1 callbacks then we’d raise an exception.

    And what if you had multiple outputs in one of the callbacks? prevent_initial_call would prevent the entire callback from being fired, so you would need to split up this callback. Unless we added prevent_initial_call to Output: Output('graph', 'figure', prevent_initial_call=True).

    Alternatively, we could by default call none of the outputs and then the user could add initial_call=True to just a single callback output.

  • It doesn’t add any new functionality.

    As far as I understand, every use case for Duplicate Callback Outputs can be solved using triggered as in the example above.

    In the spirit of the Zen of Python, “There should be one-- and preferably only one --obvious way to do it.”

    However you could make the argument that using triggered isn’t obvious. It certainly requires some mental gymnastics. A canonical documentation example could help here.

  • Clean code & ergonomics

    The triggered example is only 2 lines longer than the multiple callback example.

    In the first example, the callback is a multiplexer that calls different functions. This is pretty clean, unambiguous, and organized.

    However, if you put the contents of draw_graph and reset_graph into the if/elif/else statements then I can imagine that this function could become quite unwieldy.

    From a mental gynmastics perspective, we’ve certainly seen that users naturally write multiple callback Output, so there’s something to be said about supporting the “natural”/“self-discoverable” thing.

  • More broadly, Dash’s reactive architecture is based off of a Directed-Acyclic-Graph where a node only exists “once”. This is similar to e.g. Excel. You can see this graph in the Dash devtools. If we built this into Dash, we would either need to relax the DAG or wire this into the callback processing logic on the backend, in which case the DAG diagram in the devtools wouldn’t exactly match what the structure of the user’s Dash app code (the output node would only exist once, even though it would have multiple app.callback declarations).

  • triggered syntax - Another solution would be to provide a more ergonomic

    triggered_id = callback_context.triggered[0]['prop_id']
    

    For example, we could make a new triggered_dict that would have the same content but be a dictionary instead of a list. The logic would become:

    def update_graph(b1, b2):
        if 'reset.n_clicks' in dash.callback_context.triggered_map:
            return reset_graph()
        elif 'draw.n_clicks'  in dash.callback_context.triggered_map:
            return draw_graph()
    

    which would save another line of code.

Moving Forward

First off, we need to publish the canonical example above in our documentation to make it more discoverable. And we should link the error message to this documentation page. The current error message leaves a lot of the details out :slight_smile:

Beyond that, I’d love to hear the community’s thoughts on the tradeoffs above and if you have any other suggestions for this problem. Thanks for reading!

2 posts - 2 participants

Read full topic


Viewing all articles
Browse latest Browse all 6271

Trending Articles