Recipe: Server-Driven (Dynamic) UI

Goal: let the server shape structure, not just fill values — render a variable number of items, generate whole tabs from data, and pull on a timer. Concepts: Layouts, Data Flow — sync & actions.

Three mechanisms

Mechanism Declared where Driven by
Dynamic group a group with "dynamic": "<event>" a broadcast tagged msg_type: "<event>"
Dynamic tabs layout-level dynamicTabs: [{ "event": "<event>" }] a broadcast tagged msg_type: "<event>"
Poll group layout-level pollGroups: { name: { event, interval, payload } } the device emits event every interval

The first two are push (your server decides when to reshape the UI). The third is pull (the device asks on a schedule; your server answers by broadcasting).

Dynamic group (variable-length content)

A group declares the event that fills it:

{ "type":"group", "id":"sensors", "position":[0,0], "span":[6,4],
  "grid": { "columns": 2, "rows": 6 }, "dynamic": "sensors" }

Your server broadcasts a frame whose msg_type matches the group's dynamic name; the group regenerates its children from the payload:

await socket.emit("broadcast_request", { "msg_type": "sensors", "rows": [
  { "id":"t1", "label":"Inlet",  "value": 64 },
  { "id":"t2", "label":"Outlet", "value": 71 } ] })

Add or remove rows by broadcasting a new frame — the UI follows the data.

Dynamic tabs

Declare which event generates tabs:

"dynamicTabs": [ { "event": "rooms" } ]

Broadcasting { "msg_type": "rooms", … } lets your server add/replace tabs at runtime (e.g. one tab per discovered device). Combine with dynamic groups to build entire screens from server state.

Poll groups (timed pull)

When you'd rather the device ask on a cadence than keep a stream open:

"pollGroups": {
  "status": { "event": "broadcast_request", "interval": 5,
              "payload": { "msg_type": "poll", "want": "status" } }
}

Every 5 seconds the device emits that event with that payload. Your server sees the poll and responds by broadcasting fresh data the controls listen for:

@socket.on("broadcast")
async def on_broadcast(payload):
    if isinstance(payload, dict) and payload.get("msg_type") == "poll" and payload.get("want") == "status":
        await socket.emit("broadcast_request", { "msg_type": "metrics", "cpu": read_cpu() })

Use poll groups for cheap periodic refreshes; use a continuous broadcast loop (Recipe: Telemetry Dashboard) for live streams.

Choosing

  • Fixed controls, changing values → plain sync (Recipe: Telemetry Dashboard).
  • Variable number of items → dynamic group.
  • Variable number of screens → dynamic tabs.
  • Device-initiated periodic refresh → poll group.

Authoring layouts live (instead of shipping JSON): Live Editing & the MCP Authoring Loop.