Keeping multi-page app in sync without background callbacks

I’m developing a multi-page app where I need to keep the x axes of graphs on several pages synchonized. My current solution (code at the end) works, but has a major problem which I could not yet solve.

Currently, I set up a “shared” dcc.Store that keeps track of the current x axis range across all pages:
dcc.Store(id='shared-datarange', storage_type='local')
This Store updates whenever the user alters the x axis range, using the relayoutData property of the relevant graphs as Input via a pattern-matching callback. Each page in turn uses this Store’s data as Input to update their Graph’s figure accordingly. In this example they calculate the according y values using mathematical functions, but in my use case this is a potentially costly fetch operation.
This works, as seen in this GIF:

The problem arises when I have multiple parts of my app open at the same time in different tabs, which is a common scenario in my use case. The synchronization is still there, however the callbacks are still executed even in the background tab. This can cause many unnecessary callbacks, e.g. when the user shifts the x axis ten times on /app1 without looking at the tab containing /app2, the callbacks for /app2 will also trigger. I put print statements in the callbacks to illustrate this:

I noticed that the behaviour differs depending on the storage_type used for the shared dcc.Store. If I use anything other than local, the background callback does not happen anymore, but then the different tabs aren’t synchronized anymore.

My desired behaviour in the case of multiple open tabs would be this:

  • Callbacks are not executed on background tabs, to avoid unnecessary callbacks.
  • Upon selecting another tab, the current (most recently selected) datarange is used in that tab.

How could I achieve this? Is there any ways to prevent a callback from firing if the elements it targets are not currently in view (i.e. in another, not selected tab)? And how can I make it so that when the elements come into view again (i.e. the tab is selected), the callback does get fired to load the current datarange?

Here is my directory structe:

  • app.py
  • index.py
  • apps
    |-- __init__.py
    |-- app1.py
    |-- app2.py

And the code (__init__.py is empty):
app.py

import dash
app = dash.Dash(__name__, suppress_callback_exceptions=True)

index.py

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, ALL
from app import app
from apps import app1, app2

app.layout = html.Div([
	dcc.Location(id='url'),
	dcc.Store(id='shared-datarange', data=[1,10], storage_type='local'),
	html.Div(id='page-content')
])

@app.callback(Output('page-content', 'children'),
			Input('url', 'pathname'))
def display_page(pathname):
	if pathname == '/app1':   return app1.layout
	elif pathname == '/app2': return app2.layout
	return [
		dcc.Link('App 1', href='/app1'),
		html.Br(),
		dcc.Link('App 2', href='/app2'),
	]

@app.callback(Output('shared-datarange', 'data'),
					Input({'typ':'graph', 'page':ALL}, 'relayoutData'),
					prevent_initial_call=True)
def apply_relayout(relayout_data):
	try:
		triggered = dash.callback_context.triggered[0]['value']
		start = int(triggered['xaxis.range[0]'])
		end = int(triggered['xaxis.range[1]'])
		return [start, end]
	except (KeyError, TypeError):
		return dash.no_update

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

app1.py

import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output
import plotly.graph_objects as go
from app import app

layout = html.Div([
	html.H3('App 1', id='dummy1'),
	dcc.Link('Go to App 2', href='/app2'),
	dcc.Graph(id={'typ':'graph', 'page':'app1'}),
])

@app.callback(Output({'typ':'graph', 'page':'app1'}, 'figure'),
			Input('shared-datarange', 'data'),
			Input('dummy1', 'id')) # input needed for initial trigger?
def draw_graph(datarange, _):
	print('App 1 - draw_graph() triggered')
	start, end = datarange
	x = [i for i in range(start, end+1)]
	y = [i**2 for i in x]
	fig = go.Figure(go.Scatter(x=x, y=y))
	fig.update_yaxes(fixedrange=True)
	return fig

app2.py

import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output
import plotly.graph_objects as go
from app import app

layout = html.Div([
	html.H3('App 2', id='dummy2'),
	dcc.Link('Go to App 1', href='/app1'),
	dcc.Graph(id={'typ':'graph', 'page':'app2'}),
])

@app.callback(Output({'typ':'graph', 'page':'app2'}, 'figure'),
			Input('shared-datarange', 'data'),
			Input('dummy2', 'id')) # input needed for initial trigger?
def draw_graph(datarange, _):
	print('App 2 - draw_graph() triggered')
	start, end = datarange
	x = [i for i in range(start, end+1)]
	y = [10*i-i**2 for i in x]
	fig = go.Figure(go.Scatter(x=x, y=y))
	fig.update_yaxes(fixedrange=True)
	return fig

I found a way to do this myself now, though I am by no means sure it’s the best one. Maybe users with more experience could comment on it.

What I do is, instead of using shared-datarange.data as direct Input to redraw my graph, I use an intermediate dcc.Store per page that will only get written to if the tab is active, using a clientside callback and checking document.visible. I then use that Store as Input to the callback that draws the graph.

To make sure that the value is adopted when the tab is selected again, I regularly trigger the clienside callback using dcc.Interval. There may be a better way to do this as well, but alas, I know it not. At least, I found no way to set document.addEventListener("visibilitychange") and then have that trigger my callback.

To see it in action, replace the code for app2.py from the opening post with this:

import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State
import plotly.graph_objects as go
from app import app

layout = html.Div([
	html.H3('App 2', id='dummy2'),
	dcc.Link('Go to App 1', href='/app1'),
	dcc.Graph(id={'typ':'graph', 'page':'app2'}),
	dcc.Store(id='app2-datarange', data=[]),
	dcc.Interval(id='app2-refresh-interval', interval=500)
])

app.clientside_callback(
	"""
	function(n, shared, current) {
		if (document.hidden !== false)
			return window.dash_clientside.no_update;
		else if (current.length !=2 ||
				shared[0] != current[0] ||
				shared[1] != current[1])
			return shared;
		return window.dash_clientside.no_update;
	}
	""",
	Output('app2-datarange', 'data'),
	Input('app2-refresh-interval', 'n_intervals'),
	Input('shared-datarange', 'data'),
	State('app2-datarange', 'data'),
)

@app.callback(Output({'typ':'graph', 'page':'app2'}, 'figure'),
			Input('app2-datarange', 'data'))
def draw_graph(datarange):
	print('App 2 - draw_graph() triggered')
	start, end = datarange
	x = [i for i in range(start, end+1)]
	y = [10*i-i**2 for i in x]
	fig = go.Figure(go.Scatter(x=x, y=y))
	fig.update_yaxes(fixedrange=True)
	return fig

Comparing the behaviour having both tabs open and switching between them show that it accomplished exactly what I aimed for.

Hi @MichelH

I notice that you are runing your app in different windows, why if you use dcc.Tabs and dcc.Tab instead? :thinking:

dcc.Tabs has two different method to show the content, the second one just use only one html.Div to show the active tab content, then when the tab is selected only one process will be executed.

The other method has one html.Div for each tab, but the dcc.Tabs has a property to recognice wich is the active tab selected.

Hey @Eduardo, thanks for your input! Using dcc.Tabs certainly is a viable idea, but I don’t think it would work so well with my case. I have more than just the two pages in my example. In theory, there is no upper limit to how many I could have, so I don’t think I could present all of them as a tab choice. I admit I have not worked much with dcc.Tabs, but I think I will stick to my current solution for now.

Ok.

One more tip: dcc.Tabs has a property vertical that allows you to show the options as a column in the left side and the tab content in the right side, this is usefull when you have a lot of options because is showed like a menu in the left side.

If you have one graph per window, why don’t you use a dcc.Dropdown? or perhaps a better option is using a dcc.Radioitems, its a good option when you have lot of options because you can see all together and chose the item easily.

1 Like

Thanks again for the good input - I didn’t know about the vertical property!

The number of graphs varies per page, between one and six currently, but this may change in the future. They are very much unrelated and some pages actually already contain dropdowns and radioitems, so I’d llike to avoid cluttering them up further.

If I find the time though, I might try to change my current strategy to the dcc.Tabs you suggested and see if it works better. For now, my solution works as well as need be. :slight_smile:

1 Like