Skip to main content

Command Palette

Search for a command to run...

React as a Systems Layer

When React stops being a UI library and starts being the orchestration layer for processes, devices, and the hardware underneath.

Updated
23 min read
V
Senior frontend engineer with 5+ years building React systems that go beyond CRUD: real-time visualisation at 1kHz, Electron orchestration around native SDKs, WebGPU rendering, and AI-aware design systems. Conference speaker on React performance and prompt-driven systems. Author of the React Beyond UI series — 16 deep-dive posts on frontend systems engineering.

Most React you've ever seen is CRUD. Render a form, validate it, POST it, show a toast, fetch a list. That's most apps, and it's fine.

But there's a different shape of React app that doesn't get talked about much: React as the systems layer of a desktop application. The orchestrator that coordinates Electron's main process, native modules, hardware devices, worker pools, background tasks, telemetry, and the user's screen.

In those apps, React isn't drawing pixels for fun. It's the brain that decides which device to talk to, which worker to spin up, which buffer to flush, when to alert the user that the network came back. The UI is the output of that decision tree, not the point of it.

This post walks through what that shape looks like, and the patterns that hold up when React stops being a UI framework and starts being a control system.

What "Systems Layer" Means, Specifically

In a typical web app:

  • React renders forms.
  • A server holds the truth.
  • The network is the bottleneck.

In a systems-layer React app:

  • React orchestrates processes, devices, and workers.
  • Truth lives in many places (main process, devices, the cloud, local cache).
  • The bottleneck is whichever subsystem is slowest right now.

Three properties separate the two shapes.

Multiple sources of truth. A device has state. The main process has state. The cloud has state. React holds a synchronised projection of all of them, but isn't the source of any.

Long-lived sessions. A CRUD app is a series of short interactions. A systems-layer app stays open for hours, processing streams, coordinating subsystems, recovering from failures, persisting work.

Hardware adjacency. USB devices, serial ports, sensors, cameras, audio interfaces, custom hardware. React has to know they exist, but it can't talk to them directly. The orchestration layer is what bridges the renderer to the rest of the machine.

If those three properties describe your app, you're writing a systems-layer React app whether you call it that or not.

Who's Already Doing This?

A few apps living in this space:

  • VS Code, Cursor, Windsurf: language servers, debuggers, terminal sessions, extension hosts, all orchestrated by a React-shaped renderer.
  • OBS Studio's web companion and broadcasting dashboards: camera feeds, audio routing, scene switching, telemetry, all driven from a React UI on top of native pipelines.
  • Audacity, Pro Tools' web previews, Reaper companion apps: audio device routing, real-time DSP graphs orchestrated from the UI.
  • 3D Slicer, MITK (medical imaging) and biosignal acquisition platforms: hardware-connected, multi-window, telemetry-heavy.
  • Robotics teleoperation UIs (ROS web bridges, Foxglove Studio): live sensor feeds, joystick passthrough, mission scripts.
  • Industrial control dashboards (factory MES systems, lab automation): PLCs, conveyors, sensors, all surfaced through a React layer.
  • Cryptocurrency hardware wallet apps (Ledger Live, Trezor Suite): device discovery, firmware updates, transaction signing.

Different domains, same skeleton: React owns the layout and the orchestration logic; subsystems below it handle the heavy lifting; messages flow both ways through structured IPC.

The Anatomy of a Systems-Layer React App

Strip the framework names away and the shape is consistent.

The Anatomy of a Systems-Layer React App diagram

The arrows tell the architecture. Devices and filesystems are reachable only from main (native modules can't run in a renderer). The renderer talks to everything else through main, via structured IPC. Workers handle the work that would block main itself. Telemetry is a single buffer everything writes to.

This is the same skeleton as a single-page web app, with two extra layers (main process, native subsystems). The new design question becomes: which decisions live in React, which in main, which in workers? Get those boundaries right and the app scales. Get them wrong and you'll spend a year debugging cross-process state.

Think of It Like Mission Control

A spacecraft launch. The control room has screens. The screens aren't doing the launching. The engineers in the room aren't doing the launching either. They're watching telemetry, sending commands, making decisions, coordinating teams.

  • The screens are your React tree.
  • The engineers are your orchestration logic.
  • The ground stations are your main process and workers.
  • The spacecraft are your devices and external APIs.
  • The flight rules are your state machines.

The screens are not the mission. They're the surface where the mission is visible. If you collapse "what's on screen" with "what's happening," you stop being able to think clearly about either. Separate them, and both stay tractable.

Pillar 1: React Owns the Lifecycle, Not the Data

The single biggest mistake I see in systems-layer React: pulling streams of data into React state. We covered this in the real-time chapters, but it shows up here even more sharply because there are more streams.

The pattern that scales:

  • Device readouts, telemetry, logs, sample buffers: live in a store outside React, fed by main-process IPC.
  • Session state, navigation, modals, selected items: live in React.
  • Long-running orchestration state (sessions, recordings, jobs): lives in main and is mirrored in React via a subscription.
// Main: the orchestrator owns the canonical session state.
type SessionState =
  | { kind: "idle" }
  | { kind: "configuring"; deviceId: string }
  | { kind: "streaming"; deviceId: string; startedAt: number }
  | { kind: "stopped"; deviceId: string; endedAt: number; recordingId: string };

let session: SessionState = { kind: "idle" };
const listeners = new Set<(s: SessionState) => void>();

export function setSession(next: SessionState) {
  session = next;
  for (const w of BrowserWindow.getAllWindows()) {
    w.webContents.send("session:update", next);
  }
  for (const l of listeners) l(next);
}

ipcMain.handle("session:get", () => session);

The renderer subscribes once and renders against the latest:

const SessionContext = createContext<SessionState | null>(null);

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [session, setLocalSession] = useState<SessionState | null>(null);

  useEffect(() => {
    window.desktop.session.get().then(setLocalSession);
    return window.desktop.session.onUpdate(setLocalSession);
  }, []);

  return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
}

export function useSession() {
  const s = useContext(SessionContext);
  if (!s) throw new Error("SessionProvider missing");
  return s;
}

The mental model: main is the source of truth for things that outlive the renderer's lifetime (devices, sessions, jobs). The renderer holds a projection of that truth.

If you flip this, you're in for pain. A renderer reload (Ctrl+R in dev, or a crash) wipes its state. Anything authoritative in the renderer dies with it. Anything authoritative in main survives.

Pillar 2: Multi-Process State Synchronisation

Once main is the source of truth, you need a synchronisation protocol that's robust to:

  • Renderer reloads (state must be re-fetched on mount).
  • Multiple windows (every window subscribes; main fans out).
  • Race conditions (an update fires while a renderer is mid-mount).

The pattern that holds up is a versioned snapshot plus event stream.

Pillar 2: Multi-Process State Synchronisation diagram

Implementation:

// Main: every state mutation increments a version.
let stateVersion = 0;
function applyMutation(mut: Mutation) {
  state = reduce(state, mut);
  stateVersion++;
  broadcast({ kind: "update", version: stateVersion, mutation: mut });
}

// Renderer: subscribe, store latest version, drop out-of-order events.
let latestVersion = -1;
window.desktop.state.subscribe((event) => {
  if (event.version <= latestVersion) return; // stale, ignore
  latestVersion = event.version;
  applyToLocalStore(event.mutation);
});

// On mount: fetch a snapshot at the current version.
const { state: snapshot, version } = await window.desktop.state.snapshot();
latestVersion = version;
setLocalState(snapshot);

The version lets the renderer detect if it missed any events. If the next subscription event's version is greater than latestVersion + 1, the renderer requests a fresh snapshot. Cheap insurance against reconnection races.

For high-throughput state (think: live device samples), don't sync mutation by mutation. Sync summaries through structured channels and the actual data through a dedicated streaming channel (a MessagePort, a SharedArrayBuffer, an IPC batch every frame).

Pillar 3: Device Communication Patterns

A device might be a USB chip, a serial port, a Bluetooth peer, an HTTP-speaking microservice. The contracts differ. The orchestration patterns don't.

Four stages every device session goes through.

Pillar 3: Device Communication Patterns diagram

The thing that makes device code maintainable: model these stages as an explicit state machine, not a flat boolean soup.

type DeviceState =
  | { kind: "absent" }
  | { kind: "discovered"; id: string; vendor: string; model: string }
  | { kind: "connecting"; id: string }
  | { kind: "connected"; id: string; firmware: string }
  | { kind: "streaming"; id: string; startedAt: number }
  | { kind: "error"; id: string; cause: string; recoverable: boolean };

type DeviceEvent =
  | { kind: "DISCOVERED"; id: string; vendor: string; model: string }
  | { kind: "DISAPPEARED"; id: string }
  | { kind: "USER_CONNECT"; id: string }
  | { kind: "CONNECT_OK"; id: string; firmware: string }
  | { kind: "CONNECT_FAIL"; id: string; cause: string }
  | { kind: "USER_START_STREAM"; id: string }
  | { kind: "STREAM_OK"; id: string }
  | { kind: "STREAM_ERROR"; id: string; cause: string };

function reduce(state: DeviceState, event: DeviceEvent): DeviceState {
  switch (state.kind) {
    case "absent":
      if (event.kind === "DISCOVERED") {
        return { kind: "discovered", id: event.id, vendor: event.vendor, model: event.model };
      }
      return state;
    case "discovered":
      if (event.kind === "USER_CONNECT") return { kind: "connecting", id: state.id };
      if (event.kind === "DISAPPEARED") return { kind: "absent" };
      return state;
    case "connecting":
      if (event.kind === "CONNECT_OK") {
        return { kind: "connected", id: state.id, firmware: event.firmware };
      }
      if (event.kind === "CONNECT_FAIL") {
        return { kind: "error", id: state.id, cause: event.cause, recoverable: true };
      }
      return state;
    // ... rest of the transitions
    default:
      return state;
  }
}

The renderer reads the state machine and renders accordingly. The orchestrator in main owns the transitions, talks to the native SDK, and fires events.

Two things to be deliberate about.

Errors are first-class states, not exceptions. A device disconnecting in the middle of a stream is not a runtime crash. It's a transition into {kind: "error", recoverable: true} followed by an automatic retry or a user prompt. Modelling it as state makes recovery testable.

The renderer never holds device handles. A handle to a USB device, a serial port, or a Bluetooth socket is owned by main. The renderer issues commands ("USER_CONNECT") and observes results. If the renderer crashes, main detects it and closes the handle cleanly.

Pillar 4: Worker Orchestration

Some work is too heavy for main and too DOM-shaped for a worker thread. Some work is parallelisable. Some work is sandboxed-plugin territory. A systems-layer app usually has a small worker pool that the orchestrator dispatches work to.

The shape:

Pillar 4: Worker Orchestration diagram

In Node / Electron main, the choices for spawning are:

  • worker_threads: standard Node workers. Cheap, share ArrayBuffer via transfer.
  • UtilityProcess (Electron): a sandboxed Node child process with its own memory and crash isolation. Heavier than worker_threads but safer for untrusted code.
  • child_process / spawn: full subprocess if you need a different binary.

A small pool that round-robins work:

import { Worker } from "node:worker_threads";

class WorkerPool {
  private workers: Worker[];
  private next = 0;
  private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();

  constructor(size: number, scriptUrl: URL) {
    this.workers = Array.from({ length: size }, () => {
      const w = new Worker(scriptUrl);
      w.on("message", (msg: { id: string; ok: boolean; result?: unknown; error?: unknown }) => {
        const entry = this.pending.get(msg.id);
        if (!entry) return;
        this.pending.delete(msg.id);
        msg.ok ? entry.resolve(msg.result) : entry.reject(msg.error);
      });
      return w;
    });
  }

  dispatch<T>(kind: string, payload: unknown): Promise<T> {
    return new Promise((resolve, reject) => {
      const id = crypto.randomUUID();
      this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
      const w = this.workers[this.next];
      this.next = (this.next + 1) % this.workers.length;
      w.postMessage({ id, kind, payload });
    });
  }

  async terminate() {
    await Promise.all(this.workers.map((w) => w.terminate()));
  }
}

export const pool = new WorkerPool(
  Math.max(1, os.cpus().length - 2),
  new URL("./workers/decoder.js", import.meta.url),
);

The orchestrator dispatches jobs, the renderer never sees the worker pool directly. From React's perspective, "decode this recording" is a single IPC call that resolves with a result.

For long-running jobs (a multi-minute analysis, a firmware flash), pair the worker with a progress channel:

ipcMain.handle("recording:analyse", async (event, recordingId: string) => {
  const job = pool.dispatchProgressive("analyse", { recordingId });
  job.onProgress((pct: number) => event.sender.send("job:progress", { recordingId, pct }));
  return await job.result;
});
function AnalysisProgress({ recordingId }: { recordingId: string }) {
  const [pct, setPct] = useState(0);
  useEffect(() => {
    return window.desktop.jobs.onProgress(recordingId, setPct);
  }, [recordingId]);
  return <ProgressBar value={pct} />;
}

Progress is a fire-and-forget event channel. Job completion is the resolved promise of the original IPC call. React renders both naturally.

Pillar 5: Telemetry and Observability

Once your app coordinates more than three subsystems, the only sane way to debug it is telemetry. Logs, metrics, traces, structured events. Without them, "the streaming dropped a frame yesterday around 4 PM" is unsolvable.

The pattern that scales:

Pillar 5: Telemetry and Observability diagram

A telemetry event is a small typed record with a timestamp, a source, a level, and a payload:

type TelemetryEvent =
  | { kind: "log"; ts: number; source: string; level: "debug" | "info" | "warn" | "error"; message: string; data?: unknown }
  | { kind: "metric"; ts: number; source: string; name: string; value: number; tags?: Record<string, string> }
  | { kind: "span"; ts: number; source: string; name: string; durationMs: number; tags?: Record<string, string> };

class TelemetryBus {
  private buf = new Array<TelemetryEvent>(5000);
  private head = 0;
  private subscribers = new Set<(e: TelemetryEvent) => void>();

  emit(event: TelemetryEvent) {
    this.buf[this.head] = event;
    this.head = (this.head + 1) % this.buf.length;
    for (const s of this.subscribers) s(event);
  }
  subscribe(cb: (e: TelemetryEvent) => void) {
    this.subscribers.add(cb);
    return () => this.subscribers.delete(cb);
  }
  snapshot(): TelemetryEvent[] {
    return [...this.buf].filter(Boolean);
  }
}

export const telemetry = new TelemetryBus();

Every subsystem emits to the bus. The bus fans out to:

  • A local rolling log file (telemetry.log).
  • A cloud APM service (Sentry, Datadog, Honeycomb) batched every few seconds.
  • A live diagnostics panel inside the app (hidden behind a key combo) that renders the last 200 events.

That last sink is the one that makes the difference. A user reports a glitch; you have them open the diagnostics panel, copy the recent events, paste them into a bug report. You see exactly what happened across every subsystem in chronological order. The time-to-diagnose drops from days to minutes.

The React side is straightforward: a panel component subscribes to the bus and renders events in reverse-chronological order.

function DiagnosticsPanel() {
  const [events, setEvents] = useState<TelemetryEvent[]>([]);

  useEffect(() => {
    window.desktop.telemetry.snapshot().then(setEvents);
    return window.desktop.telemetry.subscribe((event) => {
      setEvents((prev) => [event, ...prev].slice(0, 200));
    });
  }, []);

  return (
    <ul className="font-mono text-xs">
      {events.map((e, i) => (
        <li key={i} className={levelColor(e)}>
          {new Date(e.ts).toISOString()} [{e.source}] {summary(e)}
        </li>
      ))}
    </ul>
  );
}

Demo 1: Device Manager UI

The first demo is the surface most systems-layer apps need: a "Devices" page that shows what's connected, their states, and lets the user act on them.

The renderer composes the state machine, the orchestrator owns it. From the user's perspective: a list, a connect button, status indicators.

function DevicesPage() {
  const devices = useDevices(); // subscribes to main's device store

  return (
    <ul>
      {devices.map((d) => (
        <DeviceRow key={d.id} device={d} />
      ))}
    </ul>
  );
}

function DeviceRow({ device }: { device: DeviceState }) {
  switch (device.kind) {
    case "discovered":
      return (
        <li>
          {device.vendor} {device.model}
          <button onClick={() => window.desktop.devices.connect(device.id)}>Connect</button>
        </li>
      );
    case "connecting":
      return <li>Connecting to {device.id}...</li>;
    case "connected":
      return (
        <li>
          {device.id} (firmware {device.firmware})
          <button onClick={() => window.desktop.devices.startStream(device.id)}>Start stream</button>
        </li>
      );
    case "streaming":
      return (
        <li>
          {device.id} streaming since {new Date(device.startedAt).toLocaleTimeString()}
          <button onClick={() => window.desktop.devices.stopStream(device.id)}>Stop</button>
        </li>
      );
    case "error":
      return (
        <li className="text-red-500">
          {device.id} error: {device.cause}
          {device.recoverable && (
            <button onClick={() => window.desktop.devices.reconnect(device.id)}>Reconnect</button>
          )}
        </li>
      );
    default:
      return null;
  }
}

Every render path is pure state-to-view. Every action is an IPC call that goes through main, which mutates the state machine, which fires an update event, which the renderer receives. Round trip, but predictable.

Demo 2: Desktop Orchestration App

A heavier demo: a desktop app that owns a long-running session involving multiple devices, recording, an analysis pipeline, and cloud sync.

The orchestrator in main wires everything together. The renderer renders state.

// main/orchestrator.ts
import { telemetry } from "./telemetry";
import { setSession } from "./session";

export async function startRecording(deviceIds: string[], subjectId: string) {
  telemetry.emit({ kind: "log", ts: Date.now(), source: "orchestrator", level: "info", message: "recording:start", data: { deviceIds, subjectId } });

  const recordingId = crypto.randomUUID();
  for (const id of deviceIds) {
    await devices.startStream(id, recordingId);
  }
  setSession({ kind: "streaming", deviceIds, recordingId, subjectId, startedAt: Date.now() });
  return recordingId;
}

export async function stopRecording(recordingId: string) {
  const session = getSession();
  if (session.kind !== "streaming") return;
  for (const id of session.deviceIds) {
    await devices.stopStream(id);
  }
  const path = await recordings.save(recordingId, session);
  setSession({ kind: "stopped", recordingId, endedAt: Date.now(), path });
  pool.dispatch("analyse", { recordingId, path });
  pool.dispatch("syncToCloud", { recordingId, path });
}

The renderer is small. Most of the complexity sits in the orchestrator. That's the point. The renderer's job is to be a thin, fast surface over a deliberate orchestration layer.

function RecordingControls() {
  const session = useSession();
  const devices = useConnectedDevices();
  const [subjectId, setSubjectId] = useState("");

  if (session.kind === "streaming") {
    return (
      <button onClick={() => window.desktop.session.stop()}>
        Stop recording ({Math.floor((Date.now() - session.startedAt) / 1000)}s)
      </button>
    );
  }

  return (
    <div>
      <input value={subjectId} onChange={(e) => setSubjectId(e.target.value)} />
      <button
        disabled={!subjectId || devices.length === 0}
        onClick={() => window.desktop.session.start(devices.map((d) => d.id), subjectId)}
      >
        Start recording
      </button>
    </div>
  );
}

Demo 3: Multi-Device Dashboard

A live dashboard that visualises data from many devices simultaneously, with the orchestration layer keeping everything in sync.

function MultiDeviceDashboard() {
  const session = useSession();
  if (session.kind !== "streaming") return <EmptyState />;

  return (
    <div className="grid grid-cols-2 gap-4">
      {session.deviceIds.map((id) => (
        <DeviceTile key={id} deviceId={id} />
      ))}
    </div>
  );
}

function DeviceTile({ deviceId }: { deviceId: string }) {
  const store = useSampleStore(deviceId); // returns the pub-sub store fed by main IPC
  return (
    <Panel title={deviceId}>
      <LivePlot store={store} />
      <DeviceStats store={store} />
    </Panel>
  );
}

The trick: each LivePlot reads from its own store, fed by main-process IPC, drawn on canvas. React mounts the tiles once, the tiles draw imperatively, the dashboard scales from one to twenty devices without React doing anything special at the render layer.

The orchestrator in main owns the per-device backpressure: if one device falls behind, its store buffers fill up, but the others keep running. The UI degrades gracefully (one plot lags, the others stay smooth) instead of collapsing as a whole.

The Repo Layout

What this looks like on disk for a real systems-layer app.

my-systems-app/
├── apps/
│   ├── desktop/
│   │   ├── src/
│   │   │   ├── main/                      # Electron main process
│   │   │   │   ├── index.ts
│   │   │   │   ├── orchestrator/
│   │   │   │   │   ├── devices.ts
│   │   │   │   │   ├── sessions.ts
│   │   │   │   │   ├── recordings.ts
│   │   │   │   │   └── jobs.ts
│   │   │   │   ├── ipc/
│   │   │   │   │   ├── devices.ipc.ts
│   │   │   │   │   ├── sessions.ipc.ts
│   │   │   │   │   └── telemetry.ipc.ts
│   │   │   │   ├── workers/
│   │   │   │   │   ├── decoder.ts
│   │   │   │   │   └── analyser.ts
│   │   │   │   └── telemetry.ts
│   │   │   ├── preload/
│   │   │   │   └── desktop.preload.ts     # contextBridge API
│   │   │   └── renderer/
│   │   │       └── index.tsx              # React entry
│   │   └── package.json
├── packages/
│   ├── ui/                                # shared React components
│   ├── state-machines/                    # reducers for devices, sessions
│   ├── ipc-contract/                      # shared types between main and renderer
│   ├── telemetry/                         # telemetry bus
│   └── sdk-wrappers/                      # native module wrappers
└── turbo.json

Three things that survive every refactor:

  • ipc-contract is its own package, depended on by both main and renderer. It owns the types of every IPC call.
  • state-machines are pure reducers, no IO, fully unit-testable. Both main and renderer import them.
  • telemetry is in a shared package so every layer can emit without circular dependencies.

Deployment Workflows

A systems-layer app has a deployment surface a CRUD app doesn't.

Auto-update. The user expects the app to update itself in the background. Use electron-updater or Squirrel. Test the update path before shipping. The first time you ship a broken auto-update is the last time you ship that app.

Rollback. A native module that worked yesterday might not work today because Chromium bumped its Node ABI. Keep the previous build around. Ship a "downgrade" path. Track the previous version in user data so you can roll back without losing the user's local state.

Channel discipline. Stable, beta, canary. A user on canary tries the new firmware support. Beta gets it next week. Stable gets it the week after. Three channels are enough; more is fiddly.

Crash reporting. Wire app.on("render-process-gone") and app.on("child-process-gone") to a crash report endpoint. Include the telemetry tail in the crash payload.

app.on("render-process-gone", (event, webContents, details) => {
  reportCrash({
    kind: "renderer",
    reason: details.reason,
    exitCode: details.exitCode,
    telemetryTail: telemetry.snapshot().slice(-200),
  });
});

CI matrix. macOS Apple Silicon, macOS Intel, Windows x64, Linux x64 (sometimes arm64). Build, sign, notarise on each. Don't skip any. A user on the platform you skipped will find the bug.

Security Considerations

The blast radius of a vulnerability in a systems-layer app is large. Five rules.

Strict contextBridge. contextIsolation: true, nodeIntegration: false, sandbox: true. No exceptions. The renderer doesn't get Node access. Ever.

Validate every IPC call. Use Zod or another runtime validator on the main side. The renderer is hostile territory by default; treat anything coming from it as untrusted.

const ConnectInput = z.object({ deviceId: z.string().uuid() });

ipcMain.handle("devices.connect", (event, raw) => {
  const parsed = ConnectInput.safeParse(raw);
  if (!parsed.success) {
    telemetry.emit({ kind: "log", source: "ipc", level: "warn", ts: Date.now(), message: "invalid input", data: parsed.error });
    return { ok: false, error: "invalid input" };
  }
  return devices.connect(parsed.data.deviceId);
});

Per-handler authorisation. Don't expose admin-only handlers in the same IPC namespace as user handlers. If you have a "factory reset" function, gate it behind a separate channel that's only enabled in dev or with explicit confirmation.

Native module integrity. Sign your native binaries. Verify the signature at startup. If the binary doesn't match what shipped, refuse to load.

No remote loading. Don't load remote URLs into a BrowserWindow. If you absolutely must (auth flow, embedded help), use a separate window with strict CSP and no preload.

Scalability Notes

A systems-layer app scales differently than a web app. The dimensions that matter:

  • Number of windows. Each window is a renderer process. Three windows is roughly three browser tabs of RAM. Audit memory per window, not total.
  • Number of devices. Each device session adds an IPC channel, a buffer, possibly a worker. The orchestrator should track these in a registry, not a flat array.
  • Long-session memory. Hour-long sessions reveal leaks that one-minute sessions don't. Run smoke tests overnight.
  • Concurrent jobs. The worker pool sizes the concurrency. Match it to navigator.hardwareConcurrency - 1 or os.cpus().length - 2. Leave headroom for main and the renderer.
  • Telemetry volume. A chatty subsystem can flood the bus. Sample aggressively. A metric every second is enough; one per frame is too many.

If your app does any of these poorly, users notice slowly: the app feels great on Monday, sluggish by Thursday, restarts fix it. That's a leak, not a bug. Find the unbounded growth.

Architecture Comparisons

A few alternatives worth knowing.

Tauri. Rust backend, web renderer. Smaller bundles, lower memory, but the backend is Rust, not Node. Faster to learn if you know Rust, slower if you don't. Good when binary size and memory are existential constraints.

Native UI (Cocoa, WinUI, Qt). True native feel, full platform APIs. Higher development cost, harder to share code between platforms. Right answer when the app has to feel platform-native (Apple's apps, professional creative tools).

WebView + native shell (Capacitor, NW.js). Lighter than Electron but with similar tradeoffs. Capacitor leans mobile-first.

Web app + USB / Bluetooth / Serial via WebUSB / WebBLE / WebSerial. Browsers added these APIs. For some devices, you can ship a pure web app and skip the Electron layer entirely. Worth checking if your hardware speaks one of these protocols and your users will install a PWA.

The honest decision: if you need a Node runtime under React and a real desktop binary, Electron is still the default. The bundle and memory cost is real. The ecosystem and time-to-ship usually justify it.

Should You Use React This Way?

The patterns above are overkill for most apps. The honest case for them:

  • You have multiple subsystems (devices, workers, cloud APIs, filesystems).
  • Sessions are long, and state survives across reloads.
  • Users depend on the app working continuously, not transactionally.
  • "It crashed at 3 PM" needs to be diagnosable from the logs alone.

If any of those are true, the patterns earn their cost. If none of them are, you're building a CRUD app with extra steps.

The boring rule: start with the simplest shape that could work. Add layers (state machines, telemetry buses, worker pools) when a specific pain point demands them. Reach for the full systems-layer architecture only when the app is clearly that shape.

What Carries Over From Web React

The good news, repeating from earlier in the series: 95% of your React knowledge transfers. Components, hooks, state management, routing, the lot. What's different is the shape of the application, not the language of React.

The interesting design lives at the boundaries:

  • What goes in IPC, what stays in the renderer.
  • What's authoritative in main, what's projected into React.
  • What runs in a worker, what runs inline.
  • What's logged, what's metric, what's traced.

Get those boundaries right and the system stays maintainable as it grows. Get them wrong and you'll discover, six months in, that a "small refactor" is actually rewriting half the app.

Wrapping Up

React isn't always a UI library. In a systems-layer app, it's the orchestration surface for processes, devices, workers, and the hardware underneath. The components you write end up being a thin, declarative projection of a much larger machine.

Three rules that hold up across every systems-layer React app I've built:

  1. React owns the lifecycle, not the data. The renderer is a projection. Main is the source of truth.
  2. State machines, not booleans. Devices, sessions, jobs, any long-lived process gets an explicit enum with reducible transitions.
  3. Telemetry from day one. Logs, metrics, traces. A bus everything writes to and several sinks read from. You'll thank yourself the first time a user reports a glitch from yesterday.

Get those three right and your React app stops being a UI and starts being a system. The UI is still there. It's just no longer the centre of the design.

#reactjs #electron #systems #architecture #javascript