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['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, y=df.columns) 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, y=df.columns) @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.
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.
triggeredexample, you can have a final
elsestatement that handles this case.
In order for this to be unambiguous, we’d need the user to set
prevent_initial_callfor N-1 of their callbacks. At that point, writing Duplicate Callback Outputs would probably be the same lines of code as using
triggeredand multiple functions. If the user didn’t specify
prevent_initial_callfor N-1 callbacks then we’d raise an exception.
And what if you had multiple outputs in one of the callbacks?
prevent_initial_callwould prevent the entire callback from being fired, so you would need to split up this callback. Unless we added
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=Trueto 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
triggeredas 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
triggeredisn’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
if/elif/elsestatements 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
triggeredsyntax - Another solution would be to provide a more ergonomic
triggered_id = callback_context.triggered['prop_id']
For example, we could make a new
triggered_dictthat 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.
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
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!