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.