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, part 4, part 5, part 6, part 7 part 8 and part 9 .
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.
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.
// preload.js
import { contextBridge, ipcRenderer } from "electron";
const electronHandler = {
store: {
get(key) {
return ipcRenderer.sendSync("get-store", key);
},
set(property, val) {
ipcRenderer.send("set-store", property, val);
},
},
// ...others code
};
contextBridge.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.
import Store from "electron-store";
const store = new Store();
ipcMain.on("get-store", async (event, val) => {
event.returnValue = store.get(val);
});
ipcMain.on("set-store", async (_, key, val) => {
store.set(key, val);
});
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.
window.electron.store.set("key", "value");
window.electron.store.get("key");
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).
export const sendToAll = (channel, msg) => {
BrowserWindow.getAllWindows().forEach(browseWindow => {
browseWindow.webContents.send(channel, msg);
});
};
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 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.
store.onDidChange("key", newValue => {
// TODO
});
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.
import Store from "electron-store";
const store = new Store();
const subscriptions = new Map();
ipcMain.on("get-store", async (event, val) => {
event.returnValue = store.get(val);
});
ipcMain.on("set-store", async (_, key, val) => {
store.set(key, val);
});
ipcMain.on("subscribe-store", async (event, key) => {
const unsubscribeFn = store.onDidChange(key, newValue => {
sendToAll(`onChange:${key}`, newValue);
});
subscriptions.set(key, unsubscribeFn);
});
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.
//... other codes
ipcMain.on("unsubscribe-store", async (event, key) => {
subscriptions.get(key)();
});
Let's update the preload script to support the store's subscription/unsubscribing.
// preload.js
import { contextBridge, ipcRenderer } from "electron";
const electronHandler = {
store: {
get(key) {
return ipcRenderer.sendSync("get-store", key);
},
set(property, val) {
ipcRenderer.send("set-store", property, val);
},
subscribe(key, func) {
ipcRenderer.send("subscribe-store", key);
const subscription = (_event, ...args) => func(...args);
const channelName = `onChange:${key}`;
ipcRenderer.on(channelName, subscription);
return () => {
ipcRenderer.removeListener(channelName, subscription);
};
},
unsubscribe(key) {
ipcRenderer.send("unsubscribe-store", key);
},
},
// ...others code
};
contextBridge.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.
window.electron.store.subscribe("user", newUser => {
// TODO
});
React provides a hook to connect to an external store called
useSyncExternalStore. It expects two functions as arguments.
subscribe function should subscribe to the store and return an
unsubscribe function.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.
class SyncedStore {
snapshot;
defaultValue;
storageKey;
constructor(defaultValue = "", storageKey) {
this.defaultValue = defaultValue;
this.snapshot = window.electron.store.get(storageKey) ?? defaultValue;
this.storageKey = storageKey;
}
getSnapshot = () => this.snapshot;
subscribe = callback => {
// TODO
};
}
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.
class SyncedStore {
snapshot;
defaultValue;
storageKey;
constructor(defaultValue = "", storageKey) {
this.defaultValue = defaultValue;
this.snapshot = window.electron.store.get(storageKey) ?? defaultValue;
this.storageKey = storageKey;
}
getSnapshot = () => this.snapshot;
subscribe = callback => {
window.electron.store.subscribe(this.storageKey, callback);
return () => {
window.electron.store.unsubscribe(this.storageKey);
};
};
}
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.
class SyncedStore {
snapshot;
defaultValue;
storageKey;
listeners = new Set();
constructor(defaultValue = "", storageKey) {
this.defaultValue = defaultValue;
this.snapshot = window.electron.store.get(storageKey) ?? defaultValue;
this.storageKey = storageKey;
}
getSnapshot = () => this.snapshot;
onChange = newValue => {
if (JSON.stringify(newValue) === JSON.stringify(this.snapshot)) return;
this.snapshot = newValue ?? this.defaultValue;
this.listeners.forEach(listener => listener());
};
subscribe = callback => {
this.listeners.add(callback);
if (this.listeners.size === 1) {
window.electron.store.subscribe(this.storageKey, this.onChange);
}
return () => {
this.listeners.delete(callback);
if (this.listeners.size !== 0) return;
window.electron.store.unsubscribe(this.storageKey);
};
};
}
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.
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.
import { useSyncExternalStore } from "react";
const createSyncedStore = ({ defaultValue, storageKey }) => {
const store = new SyncedStore(defaultValue, storageKey);
return () => useSyncExternalStore(store.subscribe, store.getSnapshot);
};
const useUser = createSyncedStore({
storageKey: "user",
defaultValue: { firstName: "Oliver", lastName: "Smith" },
});
const App = () => {
const user = useUser();
return <div>Name: {`${user.firstName} ${user.lastName}`}</div>;
};
Now if we update the user field from anywhere, let it be from any renderer
process or main.
window.electron.store.set("user", { firstName: "John", lastName: "Smith" });
The above App component will be rerendered with the latest user data.
If this blog was helpful, check out our full blog archive.