Black Lives Matter. Please consider donating to Black Girls Code today.

Bidirectional component synchronization

A few years ago, when i started using Dash, I wanted to do bidirectional component synchronization. For me, the need has arisen multiple times since then, but I never found a good solution.

To keep the functionality compatible with the standard Dash library, I have encapsulated it in a Sync component, which has the ability to synchronize properties of it’s descendants. As an example, consider the follow code,

import dash
import dash_core_components as dcc
from dash_extensions import Sync

app = dash.Dash()
app.layout = Sync([dcc.Input(id="input", value=50), dcc.Slider(id="slider", min=0, max=100, value=50)],
                  circles=[[dict(id="input", prop="value", get="parseFloat"), dict(id="slider", prop="value")]])

if __name__ == '__main__':
    app.run_server()

which keeps a slider and a input in sync (on the client side, without using callbacks),

Peek 2020-11-10 13-46

In the current syntax, the synchronization is specified as a list of dependency circles. Each circle is a list of dicts holding (component_id, component_property) specifying what properties to target. In addition a get (used to parse a value before it’s sent to other components) and/or set (used to transform a value before it’s set) function can be passed.

Does this syntax make sense? Do you have any ideas for improvement(s)? :slight_smile:

3 Likes

Hi @Emil,

For bidirectional sync, I would rather prefer the possibility to have a callback accepting both fields as inputs and outputs like in the code below.

import dash_core_components as dcc
import dash_html_components as html
from dash import Dash, callback_context, no_update
from dash.dependencies import Input, Output
from dash.exceptions import PreventUpdate

app = Dash(__name__)

app.layout = html.Div([dcc.Input(id="deg-fahrenheit"), dcc.Input(id="deg-celsius")])


@app.callback(
    [Output("deg-fahrenheit", "value"), Output("deg-celsius", "value")],
    [Input("deg-fahrenheit", "value"), Input("deg-celsius", "value")],
)
def sync_inputs(fahrenheit, celsius):
    ctx = callback_context

    if not ctx.triggered:
        raise PreventUpdate

    if ctx.triggered[0]["prop_id"] == "deg-fahrenheit.value":
        # deg-fahrenheit given, update deg-celsius
        return no_update, (fahrenheit - 32) * 5 / 9
    elif ctx.triggered[0]["prop_id"] == "deg-celsius.value":
        return celsius * 9 / 5 + 32, no_update

    raise ValueError("should never happen")

if __name__ == "__main__":
    app.run_server(debug=True)

This code will not run today as it will complain in the browser about:

Same Input and Output

This is probably to avoid loop/cycles in the dependencies. If it is just that, then maybe a light change in the Dash logic to ensure that the return of a callback cannot retrigger itself (ie simple way to avoid infinite callback loop due to a loop on a component property (https://en.wikipedia.org/wiki/Loop_(graph_theory)) would be sufficient to allow syncing of components in a generic way.

Moreover, if for each change on the UI, the timestamp of the change would be saved and transmitted to the callback (via the callback_context), it could be possible to sync 3 (or more) components together by looking at the last 2 changed components and recalculating the 3rd (or similar logic).

The syntax your propose is very coherent with the Dash echo system, but I am not sure that i can be implemented without changes to the internals of Dash (or a huge amount of hacks); that’s why i aimed for the component for starters. What would you said, @chriddyp?

One thing i guess that could be done would be to create a component that simply monitors the state of it’s children, but doesn’t do any updates; the updates would be handled by Dash. It would enable a syntax more similar to what you have suggested. Hence something like (the below is just pseudo code to illustrate what i mean),

import json
import dash_core_components as dcc
import dash_html_components as html

from dash import Dash, no_update
from dash.dependencies import Input, Output
from dash_extensions import Monitor

app = Dash(__name__)
app.layout = html.Div(
    Monitor([dcc.Input(id="deg-fahrenheit"), dcc.Input(id="deg-celsius")],
            monitors=dict(deg=["deg-fahrenheit.value", "deg-celsius.value"]))
)

@app.callback(
    [Output("deg-fahrenheit", "value"), Output("deg-celsius", "value")],
    [Input("monitor", "data")],
)
def sync_inputs(data):
    monitor = json.loads(data)["deg"]

    if monitor["trigger"] == "deg-fahrenheit.value":
        return no_update, (monitor["deg-fahrenheit.value"] - 32) * 5 / 9
    elif monitor["trigger"] == "deg-celsius.value":
        return monitor["deg-celsius.value"] * 9 / 5 + 32, no_update

    raise ValueError("should never happen")

if __name__ == "__main__":
    app.run_server(debug=True)

Indeed, this would need a change in the dash logic on client side (maybe just not triggering the error) but would allow much more flexibility (and less cognitive load for the developer).

@chriddyp , do you think it is doable?
And about adding some extra information about timestamps in the callback context?