Liquid-dash: a framework for keeping callbacks stable in dynamic Dash layouts

I wanted to share a small library I built for Dash apps with highly dynamic layouts: liquid-dash.

The problem it is trying to address is the callback churn you can get with pattern-matching callbacks in dynamic UIs. With MATCH or ALL, the callback depends directly on UI elements that are created and removed dynamically. So when new matching components enter the layout, the callback can run because the input set changed, even though the user interaction you care about did not happen.

The idea in liquid-dash is to move the callback dependency off of those dynamic controls and onto one fixed event source.

Instead of wiring dynamic buttons directly into the callback graph, each button carries metadata describing the interaction, and a delegated frontend handler routes that interaction into a stable event bridge. That bridge lives outside the subtree that gets rebuilt.

That “outside the subtree” part is important. The goal is for the callback to depend on a component that remains in place while the dynamic region changes. If the bridge sits inside the region being replaced, it becomes part of the layout churn rather than a fixed event source.

A simplified example looks like this:

html.Div([
    EventBridge("main-events"),
    DynamicRegion(
        bridge="main-events",
        children=[
            action_button(
                "Delete row",
                action="delete_row",
                target="row-17",
                payload={"row_id": 17},
            )
        ],
    ),
])

Here the metadata does two jobs:

  • bridge tells the frontend handler which stable store should receive the event
  • action, target, and payload tell the callback what happened and what it applies to

So the button itself can be dynamic, but the callback input is not which prevents those spurious clicks. The UI emits intent through a stable bridge, the app updates state from that event, and the interface rerenders from state.

I’m sure the general pattern is not new. What this library tries to do is make the semantics explicit enough that the event flow is easy to reason about and hard to screw up :slight_smile:

Very nice, @kesh .
How long did it take you to build this framework? Are you planning to expand it?

Hello @adamschroeder

It took me a week w/Claude working on a dynamic app to figure out what was/wasn’t working. The issue isn’t that Dash does not provide the tools to make a dynamic application, it is that for a large multi-file application the callback wiring discipline can become hard for an AI agent to maintain; pattern-matching callbacks need a specific guard, shared outputs need allow_duplicate, dynamic layouts need suppress_callback_exceptions (albeit just once) plus prevent_initial_call on every action callback. The agent almost always forgets one of these things on a new feature.

As for expansion, I am iterating on the design now (Claude does not have very good taste :slight_smile:). The goal is to keep this as thin and general as possible. No special types for each event or component class, any dash element just gets wrapped in an invisible Div, and the wiring turns into agnostic bridges that relay events to reducers with a once called handler to set up the routing. Possible rename to dash-relay. Will update here!

Hi, I have closed the loop on the design for this library. It is called dash-relay now and it’s on PyPI. The whole thing is five functions and one decorator, and it works with any DOM event (12 types are in the verified test matrix). I don’t think it needs to grow much further from here.

https://github.com/rekeshali/dash-relay

If anyone’s curious how it performs against pattern-matching callbacks in practice, there’s a head-to-head demo in the repo that builds the same app both ways and runs a scripted click sequence on each side. The app surface has 9 actions on components that get added and removed as the user interacts. The pure dash side wires up a pattern matching callback per action, while the dash relay side has one callback that routes actions through a stable bridge. Here’s the demo video:

For this specific test, the relay setup comes out to roughly 80% fewer round-trips, 83% less data over the wire, and 41% less click-to-last-response time on my machine.

The performance difference depends on the scale of the app. For something with dozens of dynamic components, several actions each, and larger state stores, the gap widens—which is where this approach really starts to pay off.

Thanks!

Hello @kesh,

Did you try making this suggestion against dash in GitHub?

@jinnyzor

I don’t think there’s anything to suggest, really. Issues #1681, #2872, and #3681 already address the common occurrences.

It always comes down to the matched set—the concrete list of components a pattern currently resolves to. Any time a pattern-matched component mounts or unmounts, the renderer treats it as input motion and enqueues a fire.

The two ways this shows up in practice:

  • a new component with the callback pattern is mounted/unmounted directly by an action (e.g. add row, delete folder)
  • a re-render unmounts and remounts surviving siblings as a side effect of returning a new subtree

Each has a solution:

  • an in-callback guard on the trigger (or prevent_initial_call=True when the Output is also pattern-matched); the fire still happens, you just ignore it in Python
  • use Patch() so re-renders don’t unmount components you didn’t intend to remove

A dash-relay-style pattern just sidesteps the whole thing by not subscribing inputs to patterns in the first place, i.e. there’s no matched set to manage.

@kesh

The concept could still be the same. Move some of the logic from the renderer to the component, so that when the component is mounted it triggers initial mounting callbacks (anything not prevent initial callback).

@Philippe

I think that is the key point here, we need to fix this issue: #1681 that would go well with the recent changes allowing more flexibility with pattern matching outputs: #3756

Maybe the fix is to also check prevent_initial_call when components are added to the layout via callbacks not just on initial layout.

I think it could be possible to allow for the component to bubble up and check for its registered callbacks when newly added?