Client-side event bridge for Dash with action-based dispatch in Dash-native callback shape.
Project description
Dash Relay
A tiny layer for building dynamic Dash interfaces without wiring a callback per interactive element.
One nested workspace surface (Folders → Tabs → Panels, 9 actions) built two ways in the same Dash app. Each column runs the same scripted 9-click sequence; the compare panel in the top-right aggregates:
- Round-trips —
_dash-update-componentfetches fired · ~80% fewer on the bridge side - Data sent — total bytes over the wire · ~83% less on the bridge side
- Wall time — click → last server response · ~40% less on the bridge side
Left column: pattern-matching callbacks with the canonical guard. Right column: the Dash Relay event bridge. Measured on an M1 Pro.
Why it exists
For layouts where regions are rebuilt at runtime, the same interaction
pattern repeats in many places, or interactive elements live inside
regions that come and go, the amount of callback wiring can grow
faster than the UI itself — pattern-matching callbacks, defensive
guards, and allow_duplicate coordination across many writers.
Dash Relay moves UI events off the Dash callback graph and onto a
single client-side bridge. You wrap existing Dash components with one
call; events flow into a dcc.Store; per-action handlers — declared
with the same Output / State primitives Dash already uses — are
dispatched from a single internal callback. Layouts can be rerendered
freely without touching the callback graph. The pattern-matching
approach stays cleaner for static layouts; the bridge earns its keep
once things start moving.
The whole surface
import dash_relay as relay
from dash_relay import Action, Emitter, DEFAULT_BRIDGE
from dash import Output, State
relay.install(app) # lifecycle entry
relay.validate(layout, app=app) # correctness linter
Emitter(action="...", bridge="...", target=..., payload={...}) # template
.wrap(component, **overrides) -> Component # transparent Div wrapper
.attrs(**overrides) -> dict[str, str] # raw data-relay-* dict
@relay.callback( # handler decorator
Output("state", "data"), # write target (Dash native)
Action("my.action"), # trigger (replaces Input)
State("state", "data"), # read context (Dash native)
)
def my_handler(event, state): ...
Two functions, one decorator, two classes (Emitter, Action), one
constant. Bridges are minted automatically from the actions your
handlers declare — there's no bridge() factory or store to construct.
The handler reads like a Dash callback with Action slotted in where
Input would be — same Output, same State, same positional
handler args, same return shape.
A complete app
from dash import Dash, Input, Output, State, dcc, html
import dash_relay as relay
from dash_relay import Action, Emitter
app = Dash(__name__)
# Per-action handlers — Dash-shaped callback declarations with Action
# standing in for Input. Each handler reads its current state via
# State(...) and returns the new value to write.
@relay.callback(Output("state", "data"), Action("add"), State("state", "data"))
def add_item(event, s):
return {**s, "items": [*s["items"], {"id": len(s["items"]) + 1, "text": s["draft"]}], "draft": ""}
@relay.callback(Output("state", "data"), Action("delete"), State("state", "data"))
def delete_item(event, s):
return {**s, "items": [t for t in s["items"] if t["id"] != event["target"]]}
@relay.callback(Output("state", "data"), Action("draft"), State("state", "data"))
def update_draft(event, s):
return {**s, "draft": event["details"].get("value", "")}
# Layout: one state store + your interactive elements wrapped or
# splatted with Emitter. Bridge stores are minted by install() — you
# don't put them in the layout yourself.
draft_input = Emitter(action="draft", on="input").wrap(
dcc.Input(placeholder="New task...")
)
add_button = html.Button("Add", **Emitter(action="add").attrs()) # raw splat — no wrapper Div
app.layout = html.Div([
dcc.Store(id="state", data={"draft": "", "items": []}),
draft_input,
add_button,
html.Ul(id="list"),
])
# Standard Dash renderer: state changes → HTML.
@app.callback(Output("list", "children"), Input("state", "data"))
def render(s):
delete = Emitter(action="delete")
return [
html.Li([
html.Span(t["text"]),
html.Button("x", **delete.attrs(target=t["id"])),
]) for t in s["items"]
]
# install() last — it drains the @relay.callback pool, mints one
# bridge store per unique bridge mentioned in any Action, injects them
# into the layout, and registers one dispatcher Dash callback per bridge.
relay.install(app)
if __name__ == "__main__":
app.run(debug=True)
The pieces
relay.install(app)
The lifecycle entry point. Three things happen, in order:
- The client-side runtime script (~130 lines) is registered at
/_dash_relay/dash_relay.jsand a<script>tag is injected intoapp.index_string. The runtime watches the DOM for elements carryingdata-relay-*attributes and lazily binds document-level listeners for whatever event types it finds. Event-agnostic:on=accepts any DOM event string — including non-bubbling ones likefocus/blurand custom events dispatched viaelement.dispatchEvent(new CustomEvent(...)). - The pending handler pool (populated by
@relay.callbackdecorators at import) is drained. One bridge name is collected per uniqueAction(...).bridge. For each, adcc.Storewith idrelay-bridge-<slug>is minted and injected at the layout root. - One Dash callback per bridge is registered, with
allow_duplicate=Trueon everyOutput. The callback dispatches by action name to the right handler in that bridge's pool.
Lifecycle contract. install() must be called once, after
app.layout is set. Calling it before layout is set raises
InstallError. Calling it twice raises InstallError. Reassigning
app.layout after install() removes the bridge stores — don't do
that.
Emitter(action=..., bridge=..., target=..., payload=..., source=..., on=..., prevent_default=...)
Reusable template for relay-event emission. All constructor fields are
optional; only action is required by materialization time. Two
materialization methods:
-
.attrs(**overrides)returns a dict ofdata-relay-*attributes you can splat onto an existing component:html.Button("Pin", **Emitter(bridge="cards").attrs(action="pin", target=row_id))
No wrapper Div, so CSS
>direct-child selectors and direct-child flex/grid still work. -
.wrap(component, **overrides)wraps the given component in a transparentdisplay: contentsdiv carrying the attributes:Emitter(action="pin", target=row_id, bridge="cards").wrap(html.Button("Pin"))
Use this when splatting onto the component isn't possible (third-party components that don't forward arbitrary HTML attributes).
Override semantics: replace, not merge. Emitter(payload={"a": 1}).attrs(payload={"b": 2})
yields {"b": 2} — the override is absolute. To merge: payload={**e.payload, "b": 2}.
Auto source-fill. If source isn't set on either side and .wrap()
gets a component with an id, source defaults to that id.
Target wire encoding. target accepts str, int, or dict.
Encoded as plain string for str/int (so CSS attribute selectors work
without escape gymnastics) and compact JSON for dict. Tradeoff: a
str value that looks like a digit string round-trips as int.
If you need a digit-string preserved, wrap it: target={"id": "42"}.
Action(name, bridge=None)
Identifies a (bridge, action_name) pair the handler responds to.
bridge= defaults to DEFAULT_BRIDGE ("dash-relay-bridge"). Multiple
Action(...) declarations in one @relay.callback register alias
semantics: the same handler fires for every listed pair.
@relay.callback(*deps)
Mirrors Dash's @app.callback(Output, Input, State) signature with
Action substituting for Input. Dependency parsing rules:
- At least one
Outputrequired. - At least one
Actionrequired (multiple = aliases). - Any number of
States, including zero.
from dash import Output, State
from dash_relay import Action
@relay.callback(
Output("tab_store", "data"), # write target
Action("tab.close"), # trigger
State("tab_store", "data"), # read self
State("path", "data"), # read other context
)
def close_tab(event, tab, path):
return {**tab, "tabs": [t for t in tab["tabs"] if t["id"] != event["target"]]}
Handler signature follows Dash positional convention. Output
declarations are skipped (they're response targets, not handler
inputs). Each Action slot gives you the event dict; each State
slot gives you that store's current value.
| arg | what it is |
|---|---|
event |
the full Dash Relay event dict (keys below) |
state values... |
one positional arg per State(...) declared, in declaration order |
Return shapes:
| return | effect |
|---|---|
| value | new state for the single declared Output |
| tuple | positional, must match number of declared Outputs |
dash.no_update |
every output stays untouched |
dash.Patch(...) |
passed through unchanged (single or in a tuple) |
Alias semantics. Multiple Action(...) declarations in one
decorator route every listed (bridge, action) pair through the same
handler:
@relay.callback(
Output("modal", "data"),
Action("close", bridge="modal.signup"),
Action("dismiss", bridge="modal.signup"),
State("modal", "data"),
)
def on_close_or_dismiss(event, current):
return {**current, "is_open": False}
Both (modal.signup, close) and (modal.signup, dismiss) invoke this
handler. Doesn't trigger duplicate-handler detection.
Per-bridge dispatcher. When two handlers' Actions point at
different bridges, install() registers a separate Dash callback per
bridge. Each dispatcher's Output/State union is scoped to handlers
on its bridge — no wire cost for irrelevant state.
Coexisting with non-relay writers. Bridge stores are standard
dcc.Store components. Any external @app.callback (server or
clientside) can also write to them with allow_duplicate=True. The
relay dispatcher's outputs are always declared with allow_duplicate=True
internally so coexistence works without extra effort.
Pattern-matched ids are not supported in v4. If a handler declares
an Output or State with a dict-shaped (MATCH/ALL/ALLSMALLER)
id, install() raises InstallError. Use a fixed store id, or write
a separate non-relay @app.callback for the pattern-matched case.
Event dict keys (frozen for v4):
| key | type | what it is |
|---|---|---|
action |
str |
the Action name |
bridge |
str |
the bridge that fired |
target |
`str | int |
source |
`str | None` |
payload |
`dict | None` |
type |
str |
DOM event name (click, keydown, blur, ...) — same as JS event.type |
details |
dict |
extracted browser fields off the DOM event (value, checked, key, clientX/Y, deltaX/Y, button) — same shape as CustomEvent.detail |
timestamp |
float |
seconds since epoch (client clock) |
relay.validate(layout=None, *, strict=False, app=None)
Correctness-only linter. Three checks:
duplicate-handler— two handlers register the same(bridge, action)key. Same condition that causesInstallErrorat install time; this is the pre-flight.unreachable-handler— a handler exists for a bridge that no emitter in the supplied layout writes to. Only when a layout is given.missing-handler— an emitter targets a(bridge, action)key that no handler is registered for. Only when a layout is given.
strict=True raises UnsafeLayoutError if any issues are found.
Pre-install: reads the global pending pool. Post-install: pass
app=app to read the cached handler set from the installed app.
report = relay.validate(app.layout, app=app)
if not report.ok:
for issue in report.issues:
print(f"[{issue.code}] {issue.message}")
Installation
pip install dash-relay
Examples
# Minimal starting point: one screen of panels with add/delete/retype/badge.
python examples/live_test/app.py
# Workspace-shaped app: folders → tabs → panels with 18 action types.
# The callback graph stays at 5 no matter how many entities you add
# (verified by test_workspace_demo_has_small_fixed_callback_graph).
python examples/workspace_demo/app.py
# Head-to-head: same 9-action nested surface built two ways. In-page
# timelines show every _dash-update-component fire attributed to each
# side; the top-right compare panel aggregates percent differences
# across runs (~80% fewer round-trips, ~83% less data over the wire,
# ~40% less time from click to last server response, i.e.
# server-round-trip time — client-render time is not counted).
# Measured on an Apple M1 Pro.
python examples/pattern_matching_vs_event_bridge/nested_side_by_side.py
See examples/pattern_matching_vs_event_bridge/README.md
for a deeper write-up of what the comparison demo measures and why.
Development
pip install -e .[dev]
pytest # 35 unit tests
Integration tests (real browser)
tests/test_event_types.py launches the app in a background thread
and drives a headless Chromium via Playwright to verify every claimed
DOM event type actually flows to the bridge. Opt-in because of the
browser-binary footprint:
pip install -e .[integration]
playwright install chromium
pytest # 50 tests total (35 unit + 15 browser)
# or just the browser tests:
pytest tests/test_event_types.py -v
tests/test_event_types.py auto-skips if playwright isn't installed,
so the default pytest run stays lightweight.
Scripts
scripts/record_comparison_demo.py regenerates the head-to-head GIF
used in the example's README. Requires ffmpeg on $PATH and the
[integration] extras for Playwright.
python scripts/record_comparison_demo.py
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dash_relay-2.0.0.tar.gz.
File metadata
- Download URL: dash_relay-2.0.0.tar.gz
- Upload date:
- Size: 38.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ae81226ca8cdeb15c7e82ae50dc5f27f2f53ba170f132a90940f90c6963eff10
|
|
| MD5 |
df4c4cc0a192a943e659bac7a00b0b78
|
|
| BLAKE2b-256 |
8863c03e96169577e54876bd98632276a9edc069ef9e5921079337233bceb49b
|
Provenance
The following attestation bundles were made for dash_relay-2.0.0.tar.gz:
Publisher:
publish.yml on rekeshali/dash-relay
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dash_relay-2.0.0.tar.gz -
Subject digest:
ae81226ca8cdeb15c7e82ae50dc5f27f2f53ba170f132a90940f90c6963eff10 - Sigstore transparency entry: 1360846794
- Sigstore integration time:
-
Permalink:
rekeshali/dash-relay@4522438d4ee6c8480437f111e1e7642986f31e11 -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/rekeshali
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4522438d4ee6c8480437f111e1e7642986f31e11 -
Trigger Event:
push
-
Statement type:
File details
Details for the file dash_relay-2.0.0-py3-none-any.whl.
File metadata
- Download URL: dash_relay-2.0.0-py3-none-any.whl
- Upload date:
- Size: 23.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ed1730972be5f851b5240bf6883a92134aea7e62c421910af74a3f0efad71e30
|
|
| MD5 |
e44dd05efba2ab7759cd3d772e1d7cbd
|
|
| BLAKE2b-256 |
f5a7bc8d7644d9f76681ff4de9520d7d85cd6b4ad860dd5ad0384fb5f0d35899
|
Provenance
The following attestation bundles were made for dash_relay-2.0.0-py3-none-any.whl:
Publisher:
publish.yml on rekeshali/dash-relay
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dash_relay-2.0.0-py3-none-any.whl -
Subject digest:
ed1730972be5f851b5240bf6883a92134aea7e62c421910af74a3f0efad71e30 - Sigstore transparency entry: 1360846817
- Sigstore integration time:
-
Permalink:
rekeshali/dash-relay@4522438d4ee6c8480437f111e1e7642986f31e11 -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/rekeshali
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4522438d4ee6c8480437f111e1e7642986f31e11 -
Trigger Event:
push
-
Statement type: