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 finalelse
statement that handles this case.
In order for this to be unambiguous, we’d need the user to setprevent_initial_call
for N-1 of their callbacks. At that point, writing Duplicate Callback Outputs would probably be the same lines of code as usingtriggered
and multiple functions. If the user didn’t specifyprevent_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 addedprevent_initial_call
toOutput
: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
andreset_graph
into theif/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 ergonomictriggered_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
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!