Recently, we built NeetoRecord, a loom alternative. The desktop application was built using Electron. In a series of blogs, we capture how we built the desktop application and the challenges we ran into. This blog is part 1 of the blog series. You can also read part 2, part 3 and part 4.
When building desktop applications with Electron, one of the key challenges developers often face is managing the shared state between the main process and multiple renderer processes. While the main process handles the core application logic, renderer processes are responsible for the user interface. However, they often need access to the same data, like user preferences, application state, or session information.
Electron does not natively provide a way to persist data, let alone give a synchronized state across these processes.
electron-store to store data persistently
Since Electron doesn't have a built-in way to persist data, We can use electron-store, an npm package to store data persistently. electron-store stores the data in a JSON file named config.json in app.getPath('userData').
Even though we can configure electron-store to be made directly available in the renderer process, it is recommended not to do so. The best way is to expose it via Electron's preload script.
Let's look at how we can expose the electron store to the renderer via a preload script.
1// preload.js 2import { contextBridge, ipcRenderer } from "electron"; 3 4const electronHandler = { 5 store: { 6 get(key) { 7 return ipcRenderer.sendSync("get-store", key); 8 }, 9 set(property, val) { 10 ipcRenderer.send("set-store", property, val); 11 }, 12 }, 13 // ...others code 14}; 15contextBridge.exposeInMainWorld("electron", electronHandler);
Here, we exposed a set function that calls the ipcRenderer.send method, which just sends a message to the main process. The get function calls the ipcRenderer.sendSync method, which will send a message to the main process while expecting a return value.
Now, let's add ipcMain events to handle these requests in the main process.
1import Store from "electron-store"; 2 3const store = new Store(); 4 5ipcMain.on("get-store", async (event, val) => { 6 event.returnValue = store.get(val); 7}); 8ipcMain.on("set-store", async (_, key, val) => { 9 store.set(key, val); 10});
In the main process, we created an electron-store instance and added get-store and set-store event handlers to retrieve and set data from the store.
Now, we can read and write data from any renderer process without exposing the whole electron-store class to it.
1window.electron.store.set("key", "value"); 2window.electron.store.get("key");
Synchronization
Since we sorted out the storage issue, let's look into how we can synchronize data between the main process and all its renderer processes.
Before we start synchronization, let's create a simple utility function that can send a message to all active renderer processes or, in other words, browser windows (we will use the terms renderer process and browser windows interchangeably).
1export const sendToAll = (channel, msg) => { 2 BrowserWindow.getAllWindows().forEach(browseWindow => { 3 browseWindow.webContents.send(channel, msg); 4 }); 5};
BrowserWindow.getAllWindows() returns all active browser windows, and browseWindow.webContents.send is the standard way of sending a message from main to a renderer process.
electron-store onDidChange
electron-store provides an option to add an event listener when there is a change in the store called onDidChange. This is the key feature we are going to use to create synchronization.
1store.onDidChange("key", newValue => { 2 // TODO 3});
Not all data needs to be synchronized. So, instead of adding onDidChange to every field, let's expose an API for the renderer process so that it can decide which data it needs and subscribe to it.
1import Store from "electron-store"; 2 3const store = new Store(); 4const subscriptions = new Map(); 5 6ipcMain.on("get-store", async (event, val) => { 7 event.returnValue = store.get(val); 8}); 9ipcMain.on("set-store", async (_, key, val) => { 10 store.set(key, val); 11}); 12 13ipcMain.on("subscribe-store", async (event, key) => { 14 const unsubscribeFn = store.onDidChange(key, newValue => { 15 sendToAll(`onChange:${key}`, newValue); 16 }); 17 subscriptions.set(key, unsubscribeFn); 18});
Here, we exposed another API called subscribe-store. When calling that API with a key, we listen to that field's onDidChange event. Then, when the onDidChange triggers, we call the sendToAll function we created earlier, and all the renderer processes listening to these changes will be notified with the latest data. For example, if a field called user is subscribed to changes, we send a message to all renderer processes with the new value on a channel called onChange:user. We will soon add code in the renderer process to handle this.
store.onDidChange returns the unsubscribe function for that particular key. Since we won't be unsubscribing straight away, we need to store this function for later use. Here, we are storing it in a hash map against the same key.
Let's add an option to unsubscribe as well.
1//... other codes 2 3ipcMain.on("unsubscribe-store", async (event, key) => { 4 subscriptions.get(key)(); 5});
Update preload script
Let's update the preload script to support the store's subscription/unsubscribing.
1// preload.js 2import { contextBridge, ipcRenderer } from "electron"; 3 4const electronHandler = { 5 store: { 6 get(key) { 7 return ipcRenderer.sendSync("get-store", key); 8 }, 9 set(property, val) { 10 ipcRenderer.send("set-store", property, val); 11 }, 12 subscribe(key, func) { 13 ipcRenderer.send("subscribe-store", key); 14 const subscription = (_event, ...args) => func(...args); 15 const channelName = `onChange:${key}`; 16 ipcRenderer.on(channelName, subscription); 17 18 return () => { 19 ipcRenderer.removeListener(channelName, subscription); 20 }; 21 }, 22 unsubscribe(key) { 23 ipcRenderer.send("unsubscribe-store", key); 24 }, 25 }, 26 // ...others code 27}; 28contextBridge.exposeInMainWorld("electron", electronHandler);
We add two APIs here, subscribe and unsubscribe. While unsubscribe is straightforward, subscribe might need some explanation. It exposes two arguments, a store key and a callback function, to be called when there is a change to that field.
First, we call subscribe-store to subscribe to change to that data field; then, we listen to ipcRenderer.on for any changes. For example, when there is a change to the user field, sendToAll will propagate the change, and here we are listening to it on onChange:user.
Now, from a renderer process, if it needs to be notified of changes to the user field, we can subscribe to it like below.
1window.electron.store.subscribe("user", newUser => { 2 // TODO 3});
useSyncExternalStore
React provides a hook to connect to an external store called useSyncExternalStore. It expects two functions as arguments.
- The subscribe function should subscribe to the store and return an unsubscribe function.
- The getSnapshot function should read a snapshot of the data from the store.
In the renderer process, create a SyncedStore class with subscribe and getSnapshot functions that useSyncExternalStore expects.
1class SyncedStore { 2 snapshot; 3 defaultValue; 4 storageKey; 5 constructor(defaultValue = "", storageKey) { 6 this.defaultValue = defaultValue; 7 this.snapshot = window.electron.store.get(storageKey) ?? defaultValue; 8 this.storageKey = storageKey; 9 } 10 getSnapshot = () => this.snapshot; 11 12 subscribe = callback => { 13 // TODO 14 }; 15}
Here, we created a generic class that takes a defaultValue and storageKey. While creating the object, we loaded the existing data for that field from the main store.
When React tries to subscribe to this using useSyncExternalStore, we need to call our main store's subscribe.
1class SyncedStore { 2 snapshot; 3 defaultValue; 4 storageKey; 5 constructor(defaultValue = "", storageKey) { 6 this.defaultValue = defaultValue; 7 this.snapshot = window.electron.store.get(storageKey) ?? defaultValue; 8 this.storageKey = storageKey; 9 } 10 getSnapshot = () => this.snapshot; 11 12 subscribe = callback => { 13 window.electron.store.subscribe(this.storageKey, callback); 14 return () => { 15 window.electron.store.unsubscribe(this.storageKey); 16 }; 17 }; 18}
We have our SyncedStore ready, but it's a bit inefficient; for example, if we are subscribed to the same storageKey in multiple places, it will create a subscription for each instance in the main store. That is needless IPC communications for the same data.
Let's improve this a bit so that only one subscription is registered per browser window(renderer process), and if there are multiple use cases of the same, let's handle it internally.
1class SyncedStore { 2 snapshot; 3 defaultValue; 4 storageKey; 5 listeners = new Set(); 6 constructor(defaultValue = "", storageKey) { 7 this.defaultValue = defaultValue; 8 this.snapshot = window.electron.store.get(storageKey) ?? defaultValue; 9 this.storageKey = storageKey; 10 } 11 getSnapshot = () => this.snapshot; 12 13 onChange = newValue => { 14 if (JSON.stringify(newValue) === JSON.stringify(this.snapshot)) return; 15 this.snapshot = newValue ?? this.defaultValue; 16 this.listeners.forEach(listener => listener()); 17 }; 18 19 subscribe = callback => { 20 this.listeners.add(callback); 21 if (this.listeners.size === 1) { 22 window.electron.store.subscribe(this.storageKey, this.onChange); 23 } 24 return () => { 25 this.listeners.delete(callback); 26 if (this.listeners.size !== 0) return; 27 window.electron.store.unsubscribe(this.storageKey); 28 }; 29 }; 30}
We made the change so that only one request is sent to main; the rest of the subscriptions are stored internally and respond to it when the first one is notified.
We also added additional checks to ensure that rerender is not triggered if there are no changes to the data.
Usage
Now, whenever a synchronized store for a field is needed, we just need to create an instance of this class and pass it to useSyncExternalStore.
1import { useSyncExternalStore } from "react"; 2 3const createSyncedStore = ({ defaultValue, storageKey }) => { 4 const store = new SyncedStore(defaultValue, storageKey); 5 return () => useSyncExternalStore(store.subscribe, store.getSnapshot); 6}; 7 8const useUser = createSyncedStore({ 9 storageKey: "user", 10 defaultValue: { firstName: "Oliver", lastName: "Smith" }, 11}); 12 13const App = () => { 14 const user = useUser(); 15 16 return <div>Name: {`${user.firstName} ${user.lastName}`}</div>; 17};
Now if we update the user field from anywhere, let it be from any renderer process or main.
1window.electron.store.set("user", { firstName: "John", lastName: "Smith" });
The above App component will be rerendered with the latest user data.