Architecture
How @fcannizzaro/streamdeck-react turns JSX into rendered output for Stream Deck actions.
High-Level Pipeline
React Tree ──> Reconciler ──> VNode Tree ──> Takumi ──> setImage / setFeedback
(JSX+Hooks) (host config) (VNodes) (raster) (hardware)- React Tree -- your components, hooks, state, context. Standard React.
- Reconciler -- a custom
react-reconcilerinstance in mutation mode. Manages the fiber tree, calls hooks, schedules effects, diffs updates. - VNode Tree -- on each commit the reconciler's host nodes form a plain JS tree of
{ type, props, children }objects with back-pointers for dirty propagation. - Takumi renderer -- receives the VNode tree converted directly to Takumi nodes (bypassing
React.createElement), your fonts, and target dimensions. Produces the final image buffer. - Stream Deck -- the image is encoded as a data URI and pushed via
action.setImage()for keys, or viaaction.setFeedback()for encoder displays.
Between steps 3 and 4, the 4-phase skip hierarchy can short-circuit the render at multiple points: dirty-flag check, Merkle hash + cache lookup, and post-render output dedup.
One React Root Per Action Instance
Each visible action instance on the hardware gets its own isolated React root. This means:
- Each instance has its own state, settings, and lifecycle.
- The same action placed on multiple keys shows different data per key.
- No cross-instance state leakage.
Stream Deck Canvas:
┌─────┬─────┬─────┐
│ K1 │ K2 │ K3 │ Each Kn/Dn is a separate React root
├─────┼─────┼─────┤ with its own fiber tree, hooks state,
│ K4 │ K5 │ K6 │ and render cycle.
└─────┴─────┴─────┘
D1 D2 D3 Dials also get their own roots.Root Registry
The RootRegistry is the central hub that maps Stream Deck action instances to React roots and routes SDK events to the correct root. It owns three internal maps:
- roots (actionId → ReactRoot) -- per-key and per-dial instances
- touchStripRoots (deviceId → TouchStripRoot) -- shared per-device TouchStrip roots
- touchStripActions (actionId → deviceId) -- reverse lookup for routing touch events
When an SDK event arrives (keyDown, dialRotate, touchTap, etc.), the registry dispatches it:
Adapter event (onKeyDown, onWillAppear, ...)
↓
registry.dispatch(actionId, event, payload)
↓
┌─ Per-action root? → root.eventBus.emit(event, payload)
└─ TouchStrip action? → coordinate remap → tbRoot.eventBus.emit(touchStripEvent, payload)For touchstrip actions, events are remapped: touchTap becomes touchStripTap with coordinates translated from per-encoder (200×100) to absolute strip position, and dial events gain a column field.
Root Recycling Pool
The registry includes a recycling pool that avoids destroying and recreating fiber roots during rapid willDisappear → willAppear cycles (profile switches, page navigation, action rearrangement).
willDisappear(actionId)
↓
root.suspend() — clears timers, emits willDisappear, keeps fiber root alive
↓
pool.store("actionUUID:canvasType", root)
↓
... time passes ...
↓
willAppear(newActionId, same UUID, same canvas)
↓
pool.take("actionUUID:canvasType") — returns suspended root
↓
root.resume(newActionInfo, newSettings, ...) — updates contexts, re-rendersThe pool key is ${actionUUID}:${canvasType} — a root can only be reused for the same action type on the same surface type (same component, same pixel dimensions). This ensures the recycled fiber tree is compatible.
Without recycling, each cycle costs ~5-15ms per key (fiber root destruction + creation + initial mount). With recycling, it costs ~1-3ms per key (context update + re-render). On a 32-key Stream Deck XL, this reduces profile switch latency from ~160-480ms to ~32-96ms.
The pool uses LRU eviction with a configurable maximum size (default 16 entries). When the registry is fully destroyed via destroyAll(), all pooled roots are unmounted.
TouchStripRoot
Unlike standard per-action roots, there is one TouchStripRoot per device, shared across all encoder actions on that device. It renders a single React component tree spanning the full touch strip width (800×100 pixels for 4 encoders on Stream Deck+).
TouchStripRoot (one per device)
┌───────────────────────────────────────────────────┐
│ Single React tree renders at full width (800×100) │
│ │
│ col 0 col 1 col 2 col 3 │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 200×100│ │ 200×100│ │ 200×100│ │ 200×100│ │
│ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │
│ ▼ ▼ ▼ ▼ │
│ setFeedback per encoder (sliced segments) │
└───────────────────────────────────────────────────┘The rendering path performs one full-width Takumi render producing raw RGBA pixels, then each per-encoder segment is cropped and PNG-encoded independently. The buffer pool is used to avoid GC pressure from the large raw RGBA allocations.
Columns may not be contiguous (e.g., columns [0, 1, 3] if encoder 2 is used by a different action). The TouchStrip component receives layout metadata via the useTouchStrip() hook.
See the TouchStrip guide for user-facing details on building TouchStrip components.
Flush Coordinator
When a Stream Deck XL has all 32 keys animating, or a Stream Deck+ has 8 keys and 4 encoders active, dozens of roots may request flushes in the same tick. Without coordination, they race for the USB bus, and lower-priority updates (settings changes) can starve higher-priority ones (animations, key press feedback).
The FlushCoordinator solves this with microtask-based batching and priority sorting:
- Roots call
requestFlush()— added to a pending set - At the microtask boundary, all pending roots are collected and sorted by priority
- Flushes execute sequentially in priority order (highest priority first)
- If new requests arrive during the drain, another microtask cycle is scheduled
Four priority levels:
| Priority | Mode | Condition |
|---|---|---|
| 0 | Animating | >2 renders in a 100ms window (spring/tween running) |
| 1 | Interactive | User input (keyDown, dialRotate) within 500ms |
| 2 | Normal | Default |
| 3 | Idle | No flush for >2 seconds |
Flushes are sequential (not parallel) because the Stream Deck USB connection serializes write operations — parallel pushes just queue in the USB driver. Sequential processing gives animated and interactive keys guaranteed first access to the USB bus, reducing perceived latency.
Dirty Propagation
Each VNode has a _parent back-pointer to its parent. When a node is mutated by the reconciler, markDirty walks up through _parent references, setting _dirty = true on each ancestor until it reaches the container root. This enables:
- Phase 1 skip -- the container's dirty flag is checked in O(1). If
false, the entire render is skipped. - Merkle hash efficiency -- only dirty subtrees need rehashing. A single-node mutation rehashes O(depth) nodes instead of O(n).
The dirty flags and Merkle hashes (_hash, _hashValid) are cleared after each successful flush.
Lifecycle
onWillAppear(ev)
├── Check recycling pool for dormant root (same UUID + canvas type)
│ ├── Found → root.resume(newContext) → re-render with existing fiber tree
│ └── Not found → create new React root → initial render
├── Provide context (action, device, settings, event emitters)
└── First render → setImage()
[events: keyDown, keyUp, dialRotate, ...]
├── Dispatch into the corresponding root's event emitter
├── Hooks (useKeyDown, etc.) fire callbacks
├── State updates trigger re-render
└── Render pass → setImage() (via flush coordinator)
onDidReceiveSettings(ev)
├── Update settings in context
└── Components using useSettings() re-render
onWillDisappear(ev)
├── Suspend root (timers cleared, willDisappear emitted)
├── Store in recycling pool for potential reuse
└── Delete from active roots mapWhen a root is suspended, the fiber tree stays alive — React does not run unmount effects. On resume, a new EventBus instance is created so that useEvent hooks re-subscribe (the EventBusContext value change triggers React's effect dependency check). All context values (action, device, settings) are replaced with the new action instance's data before the re-render.
Event Flow
Events from the SDK cannot directly call React hooks. Each root has an EventBus -- a typed event emitter stored in a ref-stable context:
- The adapter receives a backend event (SDK, WebSocket, etc.).
- It invokes the registered callback, which calls
registry.dispatch(actionId, event, payload). - The registry looks up the root by the action's context ID.
- It calls
root.eventBus.emit('keyDown', ev.payload). - Inside the tree,
useKeyDown(callback)has subscribed viauseEffect. The callback fires. - If the callback calls
setState, React schedules a re-render. - On commit, the reconciler triggers
resetAfterCommit, which schedules a flush (routed through the flush coordinator).
Context Provider Tree
Every action root is wrapped with four context providers. Stable contexts (set once, never change) are outermost; volatile contexts (change during lifetime) are innermost.
<RootContext.Provider>
{" "}
{/* action + device + canvas + streamDeck — immutable */}
<EventBusContext.Provider>
{" "}
{/* per-root EventBus — stable (new instance on resume) */}
<GlobalSettingsContext.Provider>
{" "}
{/* plugin-wide settings — less frequent */}
<SettingsContext.Provider>
{" "}
{/* per-action settings — most frequent */}
<PluginWrapper>
{" "}
{/* if defined in createPlugin */}
<ActionWrapper>
{" "}
{/* if defined in defineAction */}
<UserComponent />
</ActionWrapper>
</PluginWrapper>
</SettingsContext.Provider>
</GlobalSettingsContext.Provider>
</EventBusContext.Provider>
</RootContext.Provider>RootContext merges four stable values into a single provider: ActionInfo, DeviceInfo, CanvasInfo, and StreamDeckAccess. This eliminates 3 extra fiber nodes per root compared to separate providers. For 32 active roots, this saves 96 fiber nodes and their associated reconciler overhead.
| Context | Updated by |
|---|---|
RootContext | Set once on mount (immutable). New value on resume. |
EventBusContext | Stable reference. New instance on resume (forces re-subscribe). |
GlobalSettingsContext | onDidReceiveGlobalSettings, setter from useGlobalSettings() |
SettingsContext | onDidReceiveSettings, user setSettings() calls |
Manifest Generation
The Vite bundler plugin auto-generates manifest.json at build time from two sources:
- Plugin metadata from the
manifestoption in the bundler plugin config. - Action metadata auto-extracted from
defineAction({ info })calls via AST analysis during themoduleParsedhook.
defineAction({ uuid, key, info: { name, icon } })
↓ build time
moduleParsed → parse AST → extract info
↓
writeBundle → merge plugin info + extracted actions → validate → manifest.jsonNo hand-written manifest.json or codegen declaration files are needed. Action UUIDs must start with the plugin UUID prefix (e.g., "com.example.plugin."), which is validated at build time.