@fcannizzaro/streamdeck-react

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)
  1. React Tree -- your components, hooks, state, context. Standard React.
  2. Reconciler -- a custom react-reconciler instance in mutation mode. Manages the fiber tree, calls hooks, schedules effects, diffs updates.
  3. 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.
  4. Takumi renderer -- receives the VNode tree converted directly to Takumi nodes (bypassing React.createElement), your fonts, and target dimensions. Produces the final image buffer.
  5. Stream Deck -- the image is encoded as a data URI and pushed via action.setImage() for keys, or via action.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 willDisappearwillAppear 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-renders

The 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:

  1. Roots call requestFlush() — added to a pending set
  2. At the microtask boundary, all pending roots are collected and sorted by priority
  3. Flushes execute sequentially in priority order (highest priority first)
  4. If new requests arrive during the drain, another microtask cycle is scheduled

Four priority levels:

PriorityModeCondition
0Animating>2 renders in a 100ms window (spring/tween running)
1InteractiveUser input (keyDown, dialRotate) within 500ms
2NormalDefault
3IdleNo 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 map

When 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:

  1. The adapter receives a backend event (SDK, WebSocket, etc.).
  2. It invokes the registered callback, which calls registry.dispatch(actionId, event, payload).
  3. The registry looks up the root by the action's context ID.
  4. It calls root.eventBus.emit('keyDown', ev.payload).
  5. Inside the tree, useKeyDown(callback) has subscribed via useEffect. The callback fires.
  6. If the callback calls setState, React schedules a re-render.
  7. 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.

ContextUpdated by
RootContextSet once on mount (immutable). New value on resume.
EventBusContextStable reference. New instance on resume (forces re-subscribe).
GlobalSettingsContextonDidReceiveGlobalSettings, setter from useGlobalSettings()
SettingsContextonDidReceiveSettings, user setSettings() calls

Manifest Generation

The Vite bundler plugin auto-generates manifest.json at build time from two sources:

  1. Plugin metadata from the manifest option in the bundler plugin config.
  2. Action metadata auto-extracted from defineAction({ info }) calls via AST analysis during the moduleParsed hook.
defineAction({ uuid, key, info: { name, icon } })
         ↓  build time
    moduleParsed → parse AST → extract info

    writeBundle → merge plugin info + extracted actions → validate → manifest.json

No 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.

On this page