An Electron app architecture

In my Electron app, I keep the Renderer and Main process decoupled, similar to a web app. The main difference is that instead of communicating over HTTP/Websockets, the client and server communicate with IPC. But that’s an implementation detail that I’ve hidden from the bulk of my code.

The following diagram shows how the pieces fit together on the high level.

This architecture has these properties:

  • Bidirectional communication — both Main and Renderer can initiate communication to the other.
  • Leverages familiar libraries usually used in web apps — I don’t have to re-invent libraries and patterns already existing in the JavaScript ecosystem.
  • Main and renderer are decoupled.

Let me explain each property in more detail...

Bidirectional communication

To enable bidirectional communication between Main and Renderer, I expose two points in preload, rpc and receive:

contextBridge.exposeInMainWorld("ariNote", {
    rpc: (op: {
        type: "query" | "mutation" | "subscription";
        input: unknown;
        path: string;
    }) => ipcRenderer.invoke("rpc", op),
    receive: (channel: string, func: Function) => {
        const validChannels = ["app"];
        if (validChannels.includes(channel)) {
            // Deliberately strip event as it includes `sender`
            ipcRenderer.removeAllListeners(channel);
            ipcRenderer.on(channel, (event, ...args) => func(...args));
        }
    },
    appPlatform: process.platform,
});

On top of these two exposed points, I have built a layer of abstraction. That layer lets the Renderer send requests to Main via tRPC queries and mutations. Under the hood, the layer uses the exposed rpc API to send those requests and get the response via ipcRenderer.invoke promise resolution. The Main process has a tRPC router that receives the request and resolves to the response. This all is described in more detail in Using React and tRPC with Electron.

Here’s an example of how this looks in usage. The Renderer uses tRPC hooks inside of its React components:

const workspace = *trpc*.useQuery(["workspace.byId", workspaceId]);

And the tRPC router in Main has a corresponding resolver:

query("byId", {
    input: zid,
    async resolve({ctx, input: workspaceId}): Promise<Workspace> {
        const workspace = await ctx.prisma.workspace.findUnique({ //...
        //... omitted for brevity

        return {
            id: workspaceId,
            boxes
        }
    }
})

In essence, both sides use tRPC exactly as described in the tRPC docs. Creating a new API with tRPC is a joy. It provides full stack static typing without any code generation.

Main-initiated communication

As a mechanism separate from tRPC, Main can also initiate communication with Renderer by sending events with ipcRenderer.send. Renderer has a useEffect hook in a top-level component which listens to those events with the exposed ipcRenderer.on:

useEffect(() => {
    window.ariNote.receive("app", (event) => {
        console.log("Received event from main ", event);
        handleAction(event);
    });
}, [handleAction])

I use this mechanism to handle events such as user clicking a native application menu. E.g. clicking the Help → About menu, which opens a React-driven modal in Renderer:

{
    label: i18nextMainBackend.t("About"),
    click: async () => {
        sendToRenderer(mainWindow.webContents, {
            action: "about"
        });
    }
},

Or sending electron-updater events for the Renderer to respond to how it wishes (e.g. by showing a progress bar for download progress):

autoUpdater.on("download-progress", (progress: ProgressInfo) => {
    if (win?.webContents) {
        sendToRenderer(win.webContents, {
            action: "updateDownloadProgress",
            progress
        })
    }
});

Familiar libraries

Since I’ve chosen an app architecture that acts like a web app, I can leverage existing libraries and patterns in the ecosystem.

Some of the libraries I use in Renderer:

Some of the libraries I use in Main:

Using Prisma and SQLite with Electron

Prisma posed a special challenge for using with Electron. See Github issues. It was still worth it though. Even with my relatively simple database schema, Prisma gives me quite a productivity boost compared to using raw SQL.

I actually started off using better-sqlite3 (the best SQLite library I could find for Node). better-sqlite3 is an awesome library. It’s just rather low-level for my use-case. I found myself coding a high-level client, manual TypeScript types, data mapping, etc. So I did some research on the ecosystem and found Prisma. Prisma handles all those things I had started hand-rolling, so it was an easy decision to switch.

I prefer Prisma to the other ORMs in the ecosystem, because it’s not object-oriented. It’s more data-oriented. For example, queries are just JSON objects, not some chaining API. Results are JSON objects that conform to TS interfaces, not instances of classes. That fits my functional-lite programming style better than having to come up with some class hierarchy.

The downside is the Prisma query engine and migration engine binaries increase my Electron app bundle size. I need those binaries to run Prisma migrate at runtime. As I'm a team of one, that's a tradeoff I'm willing to make in exchange for developer productivity. At least for now.

Main and renderer are decoupled

The Renderer code knows almost nothing of Electron or IPC. It has only the tiny integration points mentioned above to use tRPC and receive events from Main.

The tRPC router in Main likewise knows very little of Electron. It just uses Prisma to do CRUD. On occasion it calls Electron APIs for native features. But the tRPC structure itself knows nothing of this. For all it knows, it could be responding to an HTTP client.

Rationale

In most Electron tutorials I found, the main process exposes APIs to the renderer process, and the renderer process calls those APIs directly. So you might have a renderer process directly manipulating the database or interacting with the operating system, for example.

This is not a scalable pattern. The UI code will become coupled to details it shouldn’t have to worry about. Database CRUD, Electron APIs, and managing UI interaction are separate concerns.

Keeping a gateway between main and renderer, as in a traditional web app over HTTP, decouples those concerns. Decoupling allows the client and server code to change with minimal impact to each other. For example, if I refactor my database schema, I shouldn’t have to change a bunch of React components. The React components don’t need to know about the structure of the database — if I’m storing booleans as ints, what SQL queries to run, and so on. They only need to know about the information model of the domain entities, such as notes and links.

Summary

This is my first Electron app, and this architecture has served me well so far. It follows the well-established client/server paradigm, giving each side room to grow.

What architecture did you choose for your Electron app? I'm curious to know, as I didn't find much opinion published online on Electron app architectures. Let's talk shop :)

28