Oct 29, 2023

Syncing State between Electron Contexts

In some communities, Electron has become a controversial choice for building new applications, yet it offers a nearly unmatched possibility of moving fast compared to native frameworks like SwiftUI, especially when your team is experienced in building web applications. For an upcoming product, we’ve decided to build an MVP using Electron — our primary requirement was getting it out to the first customers asap.

I tried out Electron a few years back, but never really used it on a real product, so I had to catch up on the architecture and design patterns for building production-ready apps (at least making the right engineering choices on the foundational side of things).

Process Architecture

Electron is built on an interesting architecture, combining the world of browsers with the single-process model of Node.js. Each application starts a main process. This entry point is a regular Node.js environment, which allows the use of core libraries like os, process, net, and more.

From the main process, the developer can instruct Electron to create browser windows. Each browser window launches an isolated renderer process, similar to how every browser tab is a completely isolated process to prevent any site from taking over other sites or your system. While it is still possible to supply the renderer process with access to Node.js core modules, this practice is strongly discouraged as insecure code could arbitrarily execute code on the host system.

Instead, we’ll check out the Preload Scripts feature to access sensitive APIs in a trusted environment.

Preload Scripts and Context Bridge

Instead of accessing Node modules directly in the renderer process, preload scripts allow running code in the renderer process before any other web content (and JavaScript code) is executed. Preload scripts are granted access to Node modules.

To share code using Node modules between the preload and renderer process, you cannot simply edit the global Window object. Context isolation prevents leaking (intentionally or not) any privileged APIs. Instead, the contextBridge module allows exposing APIs (functions) from the isolated world (the preload script) to the main world (the renderer process potentially executing untrusted code).

Any value sent via the context bridge is frozen. This limits the possible types to primitive values, Promises, Errors, arrays, and objects containing primitive values, and functions. Function calls are proxied while arguments and return values are frozen.

There’s one remaining puzzle piece, we don’t know how to communicate between the isolated renderer process and the main process. We’ll get to that in the next section.

Communication between Renderer ↔ Main Process

Depending on the app you are building, you might need to break out of the renderer process. For our application, we wanted to use the main process as a single source of truth for our state. We also wanted to use internal Node.js APIs for starting a local HTTP server. This logic should not live in the renderer process and yet the state should be synchronized bi-directionally.

Inter-Process Communication (IPC)

With Inter-Process Communication (IPC), main and renderer processes can exchange messages using channels. The main process can send messages to a specific renderer process by using the WebContents API of the respective window.

const someWindow = new BrowserWindow(...);
const someData = {...};
someWindow.webContents.send('some-action', someData, ...);

As we learned before, the renderer cannot simply access Node modules, including the Electron module which exports the ipcRenderer API needed to send and receive IPC messages from the renderer process. To get around this restriction, we can expose the required APIs through the context bridge. In a Preload Script, we can thus write

const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld('electronAPI', {
  onSomeAction: (callback) => ipcRenderer.on('some-action', callback)
})

In the renderer itself, we can instantiate a handler with a callback function to react to messages sent by the main process.

window.electronAPI.onSomeAction(
	(event, someData) => {
		...
	}
);

We can also send messages from the renderer process to the main process. In the Preload Script, we need to expose another function to send a message. While we could expose the entire ipcRenderer module, this would open the application up to potential abuse from untrusted content. If your main process exposes message handlers for sensitive actions, this could be dangerous.

contextBridge.exposeInMainWorld('electronAPI', {
  onSomeAction: (callback) => ipcRenderer.on('some-action', callback),
	helloMain: (name) => ipcRenderer.invoke('hello', name)
})

In the main process, we need to handle the incoming message. While we could use the one-way message flow with ipcRenderer.send(…) and ipcMain.on(…), using the two-way APIs ipcRenderer.invoke(…) and ipcMain.handle(…) supports responding from the main process. If fire-and-forget is all you need, feel free to use the former method.

const { ipcMain } = require("electron");

ipcMain.handle("hello", async (event, name) => {
	return `hello ${name}`
});

In the renderer environment, you can invoke the exposed API, it’ll just work.

const response = window.electronAPI.helloMain("world");
console.log(response);

This shows the simple communication flow between the main and renderer processes. If you ever need to enable communication between two renderer processes, keep on reading.

MessagePorts

MessagePorts are versatile two-way channels for passing messages between contexts. This could be between the main and renderer process, but it’s also possible to wire up two isolated renderer processes that could otherwise not talk to each other. After creating a MessageChannel object, you can use ipcRenderer.postMessage('port', null, [port1]) to send one port (side) of the channel over the wire. With ipcMain.on('port'), the main process will receive the channel port and use or forward it to another renderer process with webContents.postMessage.

Interestingly, it seems as if ports aren’t subject to the same context isolation rules. This means you may see moderate performance gains by running a one-time IPC call to exchange MessageChannel ports and using a MessagePort from that point on to skip the isolated Preload Script world altogether (effectively creating a direct channel between the isolated renderer process and main process).

window.postMessage

If you don’t want to use the contextBridge feature, you can set up a message event handler to handle window.postMessage invocations from the renderer in your Preload Script. In the event handler, you can forward the message to the main process using ipcRenderer.send. You can also handle incoming messages using ipcRenderer.on and forward the message to other handlers using window.postMessage. This feels a bit weird to implement now that contextBridge handles all the serialization, so I wouldn’t recommend going with this solution today.

A simple state-sharing mechanism

StateVariables

We’ve had to juggle a lot of different client states in our Electron app and introduced a simple observer-like structure, the StateVariable.

export class StateVariable<T> {
  private _value: T;
  private _listeners: ((value: T) => void)[] = [];

  constructor(initialValue: T, onChange?: (value: T) => void) {
    this._value = initialValue;
    if (onChange) {
      this._listeners.push(onChange);
    }
  }

  get value() {
    return this._value;
  }

  set value(newValue: T) {
    this._value = newValue;
    this._listeners.forEach((listener) => listener(newValue));
  }

  onChange(listener: (value: T) => void) {
    this._listeners.push(listener);
  }

  offChange(listener: (value: T) => void) {
    this._listeners = this._listeners.filter((l) => l !== listener);
  }
}

This way, references to a variable initialized in the future can be passed from launch time. You can also react to changes to persist values to some external storage, for example.

Let’s prepare the main process to communicate value updates with the renderer process.

Intuition

Previously, we learned to use the ipcMain and ipcRenderer modules provided by Electron to create two-way communication channels between the main and renderer processes. For the renderer process, we also need to restrict the exposed API surface to sending and receiving approved messages. For this, we need to use the contextBridge module.

Putting all the pieces together, we need to wire up observers on both sides and use IPC and the contextBridge to cross the isolation boundaries.

exposeToRenderer

In the main process, we want to expose a limited subset of the available state variables to the renderer process.

import { StateVariable } from "./state";
import { ipcMain } from "electron";

export function exposeStateVariableToRenderer<T>(
  browserWindow: Electron.BrowserWindow,
  name: string,
  stateVariable: StateVariable<T>,
) {
  const onStateVariableChange = (newValue: T | null) => {
    browserWindow.webContents.send(`change-state-variable:${name}`, newValue);
  };

  const onBrowserChange = (_event: Electron.IpcMainInvokeEvent, value: T) => {
    stateVariable.value = value;
  };

  return {
    init: () => {
      // if value changes, notify browser
      stateVariable.onChange(onStateVariableChange);

      // if browser value changes, notify main
      ipcMain.handle(`change-state-variable:${name}`, onBrowserChange);

      // if browser asks for current value, send it
      ipcMain.handle(`get-state-variable:${name}`, () => stateVariable.value);
    },
    dispose: () => {
      stateVariable.offChange(onStateVariableChange);
      ipcMain.removeHandler(`change-state-variable:${name}`);
    },
  };
}

Please note: The ipcMain.handle function expects unique channel names. Reusing the same variable name with multiple browser windows is thus not possible (unless you prefix the channel name).

Next, let’s receive the variable in the isolated world of Preload Scripts.

State Variable Bridge

import { contextBridge, ipcRenderer } from "electron";

export type PropagateBrowserChangeFn = typeof propagateBrowserChange;
export type RegisterStateVariableBridgeFn = ReturnType<
  typeof registerStateVariableBridge
>;
export type GetCurrentValueFn = typeof getCurrentValue;

export function getCurrentValue<T>(name: string): Promise<T | null> {
  return ipcRenderer.invoke(`get-state-variable:${name}`);
}

export function registerStateVariableBridge() {
  return function <T>(name: string, onChange: (value: T | null) => void) {
    const onMainProcessChange = (
      _event: Electron.IpcRendererEvent,
      value: T | null,
    ) => {
      onChange(value);
    };

    ipcRenderer.on(`change-state-variable:${name}`, onMainProcessChange);

    return () => {
      ipcRenderer.removeListener(
        `change-state-variable:${name}`,
        onMainProcessChange,
      );
    };
  };
}

export function propagateBrowserChange<T>(name: string, value: T | null) {
  ipcRenderer.invoke(`change-state-variable:${name}`, value);
}

declare global {
  interface Window {
    stateVariableBridge: {
      registerStateVariableBridge: RegisterStateVariableBridgeFn;
      propagateBrowserChange: PropagateBrowserChangeFn;
      getCurrentValue: GetCurrentValueFn;
    };
  }
}

export function initializeStateVariableBridge() {
  const exposed: typeof window.stateVariableBridge = {
    registerStateVariableBridge: registerStateVariableBridge(),
    propagateBrowserChange,
    getCurrentValue,
  };

  contextBridge.exposeInMainWorld("stateVariableBridge", exposed);
}

A lot going into this part. First, we provide a standardized implementation to expose state variables to the renderer through the context bridge. With window.stateVariableBridge, any consumer can react to state updates sent by the main process with registerStateVariableBridge or propagate browser changes to the main process with propagateBrowserChange. Notice how we did not add a check to the variables the renderer can access. This only works because the main process explicitly exposes variables. If we exposed all variables, we’d have to add a list of allowed variables to the Preload Script.

We’ll cover an example implementation in a second.

React Hooks

Because we love React and Hooks, we had to make it as easy as possible to support state variables in a React-like interface, similar to useState. Actually, it should be exactly like useState.

import { useEffect, useState } from "react";

export function useStateVariable<T>(name: string) {
  const [value, setValue] = useState<T | null>(null);

  useEffect(() => {
    window.stateVariableBridge.getCurrentValue<T>(name).then((v) => {
      console.log("useStateVariable got initial value", name, v);
      setValue(v);
    });
  }, [name]);

  const updateValue = (value: T | null) => {
    window.stateVariableBridge.propagateBrowserChange(name, value);
    setValue(value);
  };

  useEffect(() => {
    const unlisten = window.stateVariableBridge.registerStateVariableBridge<T>(
      name,
      (newValue) => {
        console.log(
          "useStateVariable received new value",
          name,
          newValue,
          "vs",
          value,
        );
        if (newValue === value) {
          return;
        }
        setValue(newValue);
      },
    );
    return () => {
      unlisten();
    };
  }, [name]);

  return [value, updateValue] as const;
}

Syncing state, the easy way

Let’s imagine we want to sync an access token between the main process as a source of truth and other renderer views. We’d love to make it as easy as using a state hook in the renderers.

function TokenView() {
  const [currentToken, setCurrentToken] = useStateVariable<string | null>(
    "token",
  );

	...
}

To enable the state variable bridge, we need to invoke the setup function in our Preload Script.

initializeStateVariableBridge();

Easy as that, we provide all the APIs used by the useStateVariable hook.

In the final step, we need to instruct the main process to configure and expose the state variable.

const token = new StateVariable<string | null>(null);

// ... initialize token

const mainWindow = new BrowserWindow({});

const { init, dispose } = exposeStateVariable(mainWindow, "token", token);

init();

mainWindow.on("closed", () => {
  dispose();
});

That’s it. Let’s go through the end-to-end flow once more, starting with the main process.

  1. Once the browser window has been created, the main process will register handlers to fetch the current value and receive state updates from the browser.
  2. The Preload Script exposes the API required to register listeners and send updates.
  3. The renderer process will render the React application with its components.
  4. Once the useStateVariable hook is invoked, the useEffect hook to fetch the current (initial) value will fire, triggering an ipcRenderer.invoke/ipcMain.handle request-response cycle.
  5. From this point on, the hook will trigger rerenders after receiving updated values from the main process.
  6. When the user updates the value within the browser, it will be sent back to the main process, where interested components can handle the update.

I hope you enjoyed this comprehensive introduction to inter-process communication in the Electron process model. In the end, it’s just a bunch of pipes connecting the main process to the isolated world to the renderer, and then back to the main process and other renderer processes.