Skip to main content

Client-side event bridge for Dash with a per-action handler registry.

Project description

Dash Relay

PyPI version Python versions Tests

A tiny layer for building dynamic Dash interfaces without wiring a callback per interactive element.

Head-to-head demo

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-component fetches 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, canonical 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; one server-side registry dispatches them to handlers by action name. 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

relay.install(app)                          # install the client-side runtime once
relay.bridge()                              # a dcc.Store sink (put it in the layout)
relay.emitter(component, action, ...)       # wrap a component as an event emitter
events = relay.registry(app, state="...")   # registry for server-side handlers
relay.validate(layout)                      # optional linter

@events.handler("action")                   # one per action — registers the handler
def _(state, payload, event): ...

Five functions and one decorator. That's it.

A complete app

from dash import Dash, Input, Output, dcc, html
import dash_relay as relay

app = Dash(__name__)

# Install the client-side runtime and a `<script>` tag into the app's index page.
relay.install(app)

# Wire one dispatch callback: bridge events in → state updates out.
events = relay.registry(app, state="state")


# Per-action handlers. Each receives a deepcopy of state, safe to mutate.
@events.handler("add")
def _(state, payload, event):
    state["items"].append({"id": len(state["items"]) + 1, "text": state["draft"]})
    state["draft"] = ""


@events.handler("delete")
def _(state, payload, event):
    state["items"] = [t for t in state["items"] if t["id"] != event["target"]]


@events.handler("draft")
def _(state, payload, event):
    state["draft"] = event["native"].get("value", "")


# Layout: one state store, one bridge, wrapped interactive elements.
app.layout = html.Div([
    dcc.Store(id="state", data={"draft": "", "items": []}),
    relay.bridge(),
    relay.emitter(dcc.Input(placeholder="New task..."), "draft", on="input"),
    relay.emitter(html.Button("Add"), "add"),
    html.Ul(id="list"),
])


# Standard Dash renderer: state changes → HTML. Relay stays out of this path.
@app.callback(Output("list", "children"), Input("state", "data"))
def render(s):
    return [
        html.Li([
            html.Span(t["text"]),
            relay.emitter(html.Button("x"), "delete", target=t["id"]),
        ]) for t in s["items"]
    ]


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

The pieces

relay.install(app)

Installs a ~130-line client-side script that watches the DOM for elements carrying data-relay-on attributes and lazily binds document-level listeners for whatever event types it finds. The runtime is event-agnostic: on= accepts any DOM event string — including non-bubbling ones like focus/blur and custom events dispatched via element.dispatchEvent(new CustomEvent(...)).

relay.bridge(id="bridge")

Returns a dcc.Store that acts as the event sink. Drop one in the layout. The default id "bridge" matches what emitter() and registry() target. Use relay.bridge("analytics") if you need more than one bridge.

relay.emitter(component, action, ...)

Wraps a Dash component in a transparent display: contents div that carries the event metadata. Works with html.*, dcc.*, and third-party components alike — the wrapper owns the attributes, not the component.

Keyword arguments:

kwarg purpose default
payload= JSON-serializable value passed to the handler None
on= DOM event name "click"
bridge= target bridge id "bridge"
target=, source= context values on the event (any JSON-serializable; types round-trip) None
prevent_default= calls event.preventDefault() client-side False

Curried form. Called with just an action string, emitter() returns a reusable factory — convenient for list rendering:

delete = relay.emitter("delete")
[delete(html.Button("x"), payload={"id": t["id"]}) for t in items]

relay.registry(app, state="store_id")

Returns a Registry and registers one internal Dash callback wired from the bridge to the state store. Accepts a single store id for the common case or state=["a", "b", ...] for apps that update multiple stores from one bridge. Attach per-action logic with @events.handler("action") (below).

With state=[...], handlers receive a tuple of states — (states, payload, event) — and either mutate in place (return None) or return an explicit tuple of new values. See examples/workspace_demo/app.py for a full worked example.

Escape hatch. For apps that don't fit the registry shape at all, skip registry() and write a normal @app.callback(Input("bridge", "data"), ...) yourself.

@events.handler(action)

Decorator on the registry object — where per-action application logic lives. One decorator per action name.

events = relay.registry(app, state="state")

@events.handler("add")
def _(state, payload, event):
    state["items"].append(...)

Handler signature: (state, payload, event) -> new_state | None

arg what it is
state a deep copy of the state store — safe to mutate in place
payload the user-defined payload from relay.emitter(..., payload=...)
event the full Dash Relay event dict (keys below)

Returning None keeps the mutated deep copy. Returning a value replaces the store with that value.

Event dict keys: action, target, source, event_type, native, timestamp, bridge. event["native"] carries browser-level fields extracted from the original DOM event — value, checked, key, clientX/Y, deltaX/Y, etc.

relay.validate(layout, registry=None)

Optional development-time linter. Walks the layout and reports:

  • duplicate ids — two components sharing the same id
  • empty actions — an emitter with an empty action string
  • missing bridge — an emitter targeting a bridge id that isn't present as a dcc.Store in the layout

Pass registry=events to also cross-check action strings against registered handlers:

  • orphan-emitter — an emitter's action has no matching handler (clicking that element is a no-op)
  • orphan-handler — a handler is registered for an action that no emitter in the layout uses (false-positive-prone if emitters are rendered dynamically from callbacks)
report = relay.validate(app.layout, registry=events)
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

dash_relay-1.1.2.tar.gz (26.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dash_relay-1.1.2-py3-none-any.whl (16.6 kB view details)

Uploaded Python 3

File details

Details for the file dash_relay-1.1.2.tar.gz.

File metadata

  • Download URL: dash_relay-1.1.2.tar.gz
  • Upload date:
  • Size: 26.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dash_relay-1.1.2.tar.gz
Algorithm Hash digest
SHA256 e09eb66d88b8716ec20c61fd8254d38d2702f1481423acbdaf95bf1fb1b11fb0
MD5 ec083f2ec2d73f3b9222f7c4226bc202
BLAKE2b-256 9cb8802e76967faf76877a8c1d2b3140e244a3f52d1262c0766e37a2c851a34a

See more details on using hashes here.

Provenance

The following attestation bundles were made for dash_relay-1.1.2.tar.gz:

Publisher: publish.yml on rekeshali/dash-relay

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dash_relay-1.1.2-py3-none-any.whl.

File metadata

  • Download URL: dash_relay-1.1.2-py3-none-any.whl
  • Upload date:
  • Size: 16.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dash_relay-1.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 4375fa9f3028f05772aa4b47bc7c138f06cf5aaa9d9b62f9cc485c227ae1979c
MD5 ee0ffff55c9906d4bc3f6d9f782b9df2
BLAKE2b-256 d637d08517a06076d3ec6988b497c927d83c9af9d5d5ca14d907d131442b3928

See more details on using hashes here.

Provenance

The following attestation bundles were made for dash_relay-1.1.2-py3-none-any.whl:

Publisher: publish.yml on rekeshali/dash-relay

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page