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.