Description
This is a more thorough investigation of #396 and plotly/dash-renderer#81. plotly/dash-renderer#81 was a candidate for our 1.0.0 breaking change release and this issue is in part a response to that proposal.
As with all proposed breaking changes in Dash, this issue serves to provide enough historical and technical background to allow any of our community members to participate.
plotly/dash-renderer#81 is a proposal to change our Dash callbacks are fired upon initialization. In particular, it prevents the initial callbacks from firing if properties weren't explicitly provided.
Through this analysis, I've come to the conclusion that plotly/dash-renderer#81 isn't a complete solution to the underlying issues and inconsistencies. I propose a couple of alternative solutions towards the end. These solutions will require a larger engineering effort and design discussion and so may not be solved on the timeline of our 1.0.0 breaking change release.
Technical Background
Currently, when Dash apps load, Dash fires a certain set of callbacks:
- Callbacks with inputs that exist on the page
- Callbacks with inputs that aren't themselves outputs of any other callback.
If a callback's property wasn't defined in the initial app.layout
, then it is supplied in the callback
as None
. If it was supplied, then it is provided in the callback
as that value.
For example:
app.layout = html.Div([
dcc.Input(id='input-1'),
html.Div(id='output-1'),
dcc.Input(id='input-2', value='initial value 2'),
html.Div(id='output-2'),
html.Div(id='input-3'),
html.Div(id='output-3'),
html.Div(id='output-3a'),
html.Div(id='output-3b'),
dcc.Input(id='input-a'),
dcc.Input(id='input-b', value='initial value b'),
html.Div(id='output-c')
])
@app.callback(Output('output-1', 'value'), [Input('input-1', 'value')])
def update_output_1(value):
return 'output 1 update'
@app.callback(Output('output-2', 'value'), [Input('input-2', 'value')])
def update_output_2(value):
return 'output 2 update'
@app.callback(Output('output-3', 'children'), [Input('output-2', 'children')])
def update_output_3(children):
return 'output 3 update'
@app.callback(Output('output-3a', 'value'), [
Input('output-1', 'children'),
Input('input-1', 'value')])
def update_output_3a(children, value):
return 'output 3a update'
@app.callback(Output('output-3b', 'value'), [
Input('output-1', 'children'),
Input('input-2', 'value')])
def update_output_3b(children, value):
return 'output 3b update'
@app.callback(Output('output-c', 'value'), [
Input('input-a', 'value'),
Input('input-b', 'value')])
def update_output_c(input_a, input_b):
return 'output c update'
In this example, Dash will:
- On page load, call:
update_output_1
withNone
update_output_2
with'initial value 2'
update_output_c
withNone
and'initial value 2'
- These callbacks will update the app and if any outputs are themselves inputs, then then this update will trigger another set of callbacks:
update_output_3
with'output 2 update'
update_output_3a
with'output 1 update'
andNone
update_output_3b
with'output 1 update'
and'initial value 2'
The proposed change would change Dash so that callbacks with any inputs with properties that aren't supplied aren't called on initialization.
With this change, the following callbacks would be fired on page load:
update_2
with'initial value 2'
And after output-2
is updated, the following callbacks are triggered:
update_output_3
with'output 2 update'
update_output_3b
with'output 1 update'
and'initial value 2'
Historical Context
Why are callbacks fired on page load?
Callbacks are fired on page load for consistency. Consider the following case:
app.layout = html.Div([
dcc.Input(id='input', value='NYC'),
dcc.Graph(id='graph')
])
@app.callback(Output('graph', 'figure'), [Input('input', 'value')])
def update_graph(value):
return {'layout': {'title': value}}
In the current version of Dash, the figure
property of the dcc.Graph
goes through the following transitions:
- Initialized and rendered as
None
- Dash fires off the page-load callbacks (
update_graph
), updatingfigure
to{'layout': {'title': 'NYC'}}
After initialization, the app is in the same state on page load as it would be if the dcc.Input
was empty and then the user wrote 'NYC'
. In other words, the app's initializaiton state is no different than any other state in the application: the app is completely described by the values of the input properties and not by its history or previous state.
If we didn't fire the callbacks on page load, then the figure
would remain as None
but the input would be filled with 'NYC'
. If the user deleted the text and then re-wrote 'NYC'
in the input, then the graph would have {'layout': {'title': 'NYC'}}
, which would appear odd as this is not what the graph looked like in the original state of the app even though the dcc.Input
was in the same state. This is what we mean when we say that this is "inconsistent".
plotly/dash-renderer#81 proposes changing the behaviour when value
in the dcc.Input
isn't supplied. Consider this example:
app.layout = html.Div([
dcc.Input(id='input'),
dcc.Graph(id='graph')
])
@app.callback(Output('graph', 'figure'), [Input('input', 'value')])
def update_graph(value):
return {'layout': {'title': value}}
plotly/dash-renderer#81 proposes that the update_graph
callback should not be fired as the value
property of the dcc.Input
was not provided.
In the current version of Dash, if a property isn't explicitly supplied then it is passed into the callback as None
. From the Dash user's perspective, it's the same as dcc.Input(id='input', value=None)
.
Inconsistencies with the existing behavior
Passing undefined properties as None
into the callbacks is actually a little problematic:
-
Empty Value. In many components,
None
isn't actually the property's "empty" value as it's not a state that can be achieved through user interaction. Consider the following components:dcc.Input(type='text')
- The empty state forvalue
is''
. That is, if the user deletes the text in the input box,''
is passed back to the callback, notNone
(type='number'
may have a different "empty" value, perhapsNone
)dcc.Dropdown(multi=True)
-value
is[]
when there aren't any items in the dropdown, notNone
. However, ifmulti=False
, thenNone
is the valid empty state.
This means that the Dash developer has to handle two different "empty" states of the property with logic like:
@app.callback(Output('graph', 'figure'), [Input('input', 'value')]) def update_graph(value): if value is None or value == '': return {'layout': {'title': 'No input specified, please fill in an input.'}}
-
None can be invalid. Since
None
isn't provided by the component, it isn't necessarily valid. This is non-intuitive becauseNone
was supplied to the callback from Dash and so it is assumed that it is the actual value of the property. This assumption would lead the user to believe that:dcc.Graph(figure=None)
and
dcc.Graph()
would render the same result. They might, but it's not guaranteed. It's up to the component author to enforce this.
From a component author's perspective, these two initializations are different. In the former,
figure
is supplied and its explicitly set toNone
. In the latter,figure
isn't even supplied. The component author could render the component in different ways depending on which value was supplied. Handling these differences is most often done viadefaultProps
(see next point). -
Default Value. When components are rendered in the front-end, the component author may provide default properties for the component (using e.g. React's standard
defaultProps
) if the properties aren't supplied.
For example, considerdcc.Graph
: thefigure
component could have the following states:- If
None
, then don't draw anything, not even an empty graph. - If not supplied, then set the default to be
{'data': [], 'layout': {}}
. This will draw an empty graph.
In this case,
dcc.Graph()
would default to something reasonable (an empty graph) but if the user really wanted to clear the container they could setdcc.Graph(figure=None)
.However, if the user had a callback listening to the
figure
property, then they would receiveNone
as an argument on initialization, which doesn't match what was rendered in the front-end ({'data': [], 'layout': {}}
).dcc.Graph()
would be rendered differently thandcc.Graph(figure=None)
but in both casesNone
would be passed into the callback:@app.callback(Output(...), [Input('my-graph', 'figure')]) def update_something(figure): # ...
Similarly, consider
n_clicks
inhtml.Button
.n_clicks
represents the number of times that the element has been clicked and so it's intuitive that its default value is0
(and it is). However, since Dash doesn't send this value to the callback,None
is passed into the callback, causing a lot of confusion.app.layout = html.Div([ html.Button(id='button'), html.Div(id='output') ]) @app.callback(Output('output', 'children'), [Input('button', 'n_clicks')]) def update_output(n_clicks): if n_clicks is None: # users would rather write `if n_clicks == 0` return '' else: return do_something()
- If
-
Computed Defaults. Some components have "computed" defaults. That is, their default properties can't be statically defined. For example:
dcc.Graph.id
- Computed dynamically (a random string)dcc.Location.path
- Thehref
property (among others indcc.Location
) isn't known until the component has rendered on the page as the user could be loading the page on any path, e.g./
or/some-page
dcc.Store.data
- If theStore
has data stored in local storage, then it must dynamically retrieve this data on page load.dash_table.DataTable.derived_virtual_data
- This property represents the data in a table after it has been filtered or sorted. Currently, it is not computed on initialization, but it should be. See the example here: https://dash.plot.ly/datatable/interactivity
These dynamic defaults cause confusing initialization behavior. Consider the following example:
app.layout = html.Div([ dcc.Location(id='location') html.Div(id='some-div'), ]) @app.callback(Output('some-div', 'children'), [Input('location', 'path')]) def update_output(path): ...
On page load,
update_output
is initially called withNone
and then immediately called with the actualpath
. In this case,None
doesn't actually represent the "empty" state, it's more like the "unknown" state or the "uncomputed" state.So, to handle this use case, users should write their code as:
@app.callback(Output('some-div', 'children'), [Input('location', 'path')]) def update_output(path): if path is None: raise dash.exceptions.PreventDefault elif path == '/': # home screen elif path == '/page-1': # ... # ...
With static defaults, the user could always remove the
None
checks by supplying their own initial value. For example, instead ofdcc.Input()
they could writedcc.Input(value='')
. In the case of computed defaults, the user can't do this as they don't know what the properties will be. They can't writedcc.Location(path='/')
because the the user won't necessarily land on/
when they visit the page, they may land on/page-1
.
Possible Solutions
Solution 1. Don't fire initial callbacks if inputs aren't supplied
- Change
dash-renderer
to skip firing the initial callbacks if the input properties aren't explicitly supplied - Component authors could provide fire the callbacks when the component is rendered for the first time by calling
setProps
in the component lifecycle. This would allow components with computed defaults to fire their initial callbacks (e.g.dcc.Storage.data
ordcc.Location.path
) - Properties supplied as
State
would be passed into callbacks as their default (computed or static) properties instead ofNone
Notes:
-
By omitting an initial property value, users could prevent the initial request from occurring. Think of this as an "automatic", front-end version of
raise dash.exceptions.PreventDefault
. -
Components with dynamic properties would be responsible for firing the callbacks after initialization. This means that the "consistency" of whether or not callbacks are fired upon initialization is determined by the component author on a component-by-component basis.
-
This could be confusing as dash devs would not know the initialization behaviour in advance. This may be difficult to explain to new users. Here's an example of what this documentation might look like:
On page load, Dash will go through an application initialization routine where certain callbacks will be fired. In particular:
- If you supply initial values to properties that are
dash.dependencies.Input
, then dash will fire your callbacks to compute the "initial outputs". - If your component's properties have "dynamic" defaults, then your callback may be fired with these dynamic, computed values. This varies on a component-by-component basis. Dynamic, computed component properties include:
- All of the URL properties in
dcc.Location
(these are determined via the URL of the page when the component is loaded) - The
derived
properties indash_table.DataTable
- The
data
property ofdcc.Store
iftype
islocalstorage
- All of the URL properties in
- If you omit supplying a property value, and if that property value isn't computed dynamically by the component, then Dash will not fire your callback.
Dash fires your callbacks on app start in order for your app to have consistent, default outputs. So, it is encouraged for you to supply explicit default input arguments. That is,
dcc.Input(value='')
instead ofdcc.Input()
- If you supply initial values to properties that are
-
This could improve initial page render performance time as fewer requests could be made.
-
Users would be encouraged to supply initial values of their inputs so that the initial state of the app's lifecycle would be "consistent" and so that the outputs would have default values. The main exceptions to this rule would be
n_clicks
(users could omit this so that the callback wouldn't be fired until the user clicks on the button) and the computed properties (which can't be supplied by the user). -
This method would allow certain initial requests to be ignored but not all of them. This may be confusing to users: they may expect that they could programatically ignore an initial callback that has
derived_virtual_data
as anInput
by just not supplying an initial value for that property. However, since it is computed, they can't ignore this callback. -
The default properties (computed or static) would need to be provided as
State
. In the following example,derived_virtual_data
would be equal todata
andfigure
would be something like{'data': [], 'layout': {}}
app.layout = html.Div([ dash_table.DataTable(id='datatable', data=df.to_dict('records')), dcc.Graph(id='graph'), dcc.Dropdown( id='dropdown', value='nyc', options=[{'label': 'NYC', 'value': 'nyc'}] ), html.Div(id='output') ]) @app.callback(Output('output', 'children'), [Input('dropdown', 'value')], [State('datatable', 'derived_virtual_data'), State('graph', 'figure')]) def update_output(value, derived_virtual_data, figure): # ...
-
If the property was supplied explicitly as
None
, the callback would still be fired.dcc.Dropdown(value=None)
would fire the callback butdcc.Dropdown()
would not fire the callback.
Solution 2. Fire callbacks with default (computed or static) properties
This solution would fire all of the callbacks on initialization but instead of passing in undefined properties as None
, it would use the component's static or computed default properties.
Notes:
-
If the property doesn't have a default value, it would be
None
. This would be the same as if the component had a default value and it was explicitlyNone
.- Alternatively, if the component didn't have a default property, it could be set as a new property that we call "
Undefined
", closer matching the JavaScript API.
- Alternatively, if the component didn't have a default property, it could be set as a new property that we call "
-
The mechanism for retrieving the default properties from the component would be the same mechanism that "Solution 1" would use to retrieve the properties to populate
State
-
This behaviour is easier to explain:
On page load, Dash will fire your callbacks. If you did not supply an initial value for a property that is an
Input
orState
, Dash will pass in a default property.
You can find the default properties by callinghelp
on the component (e.g.help(dcc.Dropdown)
) or viewing the component's reference table (e.g.https://dash.plot.ly/dash-core-components/dropdown
). Some of these properties are dynamically computed (e.g. the URL properties of thedcc.Location
component) and the documentation for the component should indicate this. -
Certain components frequently would have
PreventDefault
in their canonical usage, likehtml.Button
:@app.callback(Output(...), [Input('button', 'n_clicks')]) def update_output(n_clicks): if n_clicks == 0: raise dash.exceptions.PreventDefault ...
or, users would supply some default output properties:
@app.callback(Output(...), [Input('button', 'n_clicks')]) def update_output(n_clicks): if n_clicks == 0: return '' # or maybe some helper text like: # return 'Click on the button to run the model' ...
-
Since we would start passing default props back to the callbacks, the component's default set of properties would become part of the official component API. That is, we wouldn't be able to change these properties without it being considered a breaking change. So, if we go forward with one of these solutions, we should inspect the default properties for all of our components.
Architecture Discussions
Mechanisms to retrieve the default properties
In previous prototypes (#288), we've incorporated the serialized static prop types (serialized as part of the metadata.json
files created with react-docgen
) into the dynamic python classes. This solution required no changes to dash-renderer
as it would include the default properties implicitly in the serialization. That is, dcc.Dropdown()
would get serialized the same way as if it was specified dcc.Dropdown(multi=False, value=None, options=[])
. This solution reduces the complexity in dash-renderer
but it has a few flaws:
- It doesn't handle computed defaults
- It increases the size of the payload for all requests.
html
components have 10s of properties and this could add up for large layouts.
For similar reasons, plotly/dash-renderer#81 isn't a complete solution either. While its part of "Solution 1" above, it doesn't handle passing comptued data into the callback as State
. In this solution, the components provide the computed defaults on their own schedule (by calling setProps
in their component lifecycle, frequently on componentDidMount
). This means that the initialization callbacks won't necessarily have these computed values, meaning that None
would still be passed in as State
for unspecified properties.
In order to handle computed defaults, we'll need a solution that works in dash-renderer
. Here's a basic sketch of the solution:
-
(This is the current behaviour) The dev-supplied component tree is serialized in the
layout
redux store. -
TreeContainer.react
recursively crawls thislayout
object (as it does now). At each node, it would:
a. Determine which component properties areInput
orState
.
b. For those properties, retrieve the default properties of that component and merges them into the that node in thelayout
store. These default properties are static or are a function of the dev-supplied properties.
c. Render the component with this new set of properties.Alternatively, b and c could be reversed:
dash-renderer
could render the component (letting the component itself work out its default properties) and then extract the properties from the component.
I have not investigated if there is a standard way to retrieve the default props from a component. In an ideal world, dash-renderer
would be able to introspect defaultProps
or call getDefaultProps
. I suspect that this isn't possible without instantiating the component first. If so, we would need to extract the props after instantiating the component and we'd need to ensure that the component's default properties have been computed at this point. For example, if a component computes its default props in componentDidMount
, this will not be called until the component is rendered (which, at this point in the app's lifecycle, the component is only instantiated, it hasn't been rendered yet).
If it's not possible to extract these properties via the React component classes/instances, then we could define a new Dash-specific component class method like computeDefaultProps(props)
and call that before rendering. This would increase the component authoring complexity and so it would be preferable if we could avoid this.