React: Custom renderer for the stream deck

January 29, 2025

ReactJS + Stream Deck

The stream deck is a small device with customizable LCD keys, targeted at streamers.

When I got one, I wanted to build custom automations on it, for desktop shortcuts and for piloting IOT devices of my home office.

And as expected for that kind of device, software support for linux is poor, but there is at least a way of interacting with it through the USB connection.

Stream Deck NodeJS library

I quickly found and tested the NodeJS library @elgato-stream-deck/node.

The documentation is straightforward, you can easily interact with your device display:

// connect to the device
const myStreamDeck = await openStreamDeck(devices[0].path);

// draw a color on one LCD key
await myStreamDeck.fillKeyColor(4, 255, 0, 0);

// Handle press events
myStreamDeck.on('down', (keyIndex) => {
    console.log('key %d down', keyIndex);
});
myStreamDeck.on('up', (keyIndex) => {
    console.log('key %d up', keyIndex);
});

Writing UI with imperative tools? no way.

As I was already planning to build some complex UI on it, by navigating through pages of buttons, I didn't want to go through the hassle of managing this with imperative javascript.

I much prefer to work with React for building UIs, as it is declarative. React takes a state in input, builds a virtual DOM from your declarative components, and forwards it to a renderer.

React is most often used for the web with a separate package, react-dom, to build the HTML from the virtual DOM. You can use other renderers (react-native is a famous one), or you can build you own renderer.

To use React on the stream deck, that's what I'm going to do.

Building a React renderer

I'm following the legacy way of using React to keep things simple.

You probably remember this syntax:

ReactDOM.render(<App/>, document.getElementById('root'))

Let's do something similar:

import {openStreamDeck} from '@elgato-stream-deck/node';
import StreamDeckRenderer from 'stream-deck-react-renderer';

const deck = await openStreamDeck('...');
StreamDeckRenderer.render(<App />, deck);

The render() method we created is relying on the React reconciler:

import type { ReactNode } from "react";
import type { StreamDeck } from "@elgato-stream-deck/node";
import Reconciler from "react-reconciler";
import renderer from './renderer';

const reconciler = Reconciler(renderer);

export default {
  render(element: ReactNode, deck: StreamDeck) {
    const container = reconciler.createContainer(deck, 0, null, true, null, "streamdeck", (err) => console.error(err), null);
    reconciler.updateContainer(element, container, null, null);
  },
};

And finally, our own renderer. (It's slightly too long to be included here.)

Prototype

I wrote the following component to interactively test the renderer:

export default () => {
  const [started, setStarted] = useState<boolean>(false);
  const [lights, setLights] = useState<boolean[]>(Array(14).fill(false));

  useEffect(() => {
    if (!started) return;

    // Turn on a random LCD key every 0.8s
    const interval = setInterval(() => {
      const index = Math.floor(Math.random() * 14);
      setLights(lights => lights.toSpliced(index, 1, true));
    }, 800);

    return () => clearInterval(interval);
  }, [started]);

  return (
    <>
      {lights.map((shown, index) =>
        <lcdKey
          key={index}
          color={shown ? '#10404e' : '#000000'}
          onPress={() => {
            setLights(lights => lights.toSpliced(index, 1, false));
          }}
        />
      )}
      <lcdKey
        position={14}
        image={resolve(__dirname, '../assets/start.jpg')}
        onPress={() => {
          setStarted(status => !status);
          setLights(Array(14).fill(false));
        }}
      />
    </>
  );
};

And it's working 🎉

I can display a picture or a specific color on each LCD key, and manage pressed buttons.
Good enough for now.

React Prototype on Stream Deck