When building Electron apps, you’ll inevitably need to reach for a storage solution to persist your data across sessions (like saving user data or preferences). You can rely on the web’s localStorage or even cookies - but sometimes you want something a little more robust. For Electron, the easiest option is electron-store, a key-value storage very similar to localStorage.

Why use electron-store?

There are a few clear reasons:

  • Local Storage and cookies require you to JSON.stringify any complex data sets. So if you have an object, array, or even a boolean — it’ll ultimately need to be converted to JSON and back. electron-store lets you store complex data directly and handles the serialization to JSON for you.
  • electron-store is built with ajv, a data validation library (similar to Yup). This allows you to set a specific schema for your data and have it immediately validated when stored.
  • The other alternative to localStorage on Electron is basically using NodeJS and it’s filesystem APIs to write data to disk (like JSON to a .json file).
  • And of course, you could always leverage an actual database locally, but this would require a lot of setup and integration into Electron.

Why Typescript?

When you use Electron store inside your app to get() data, the variables will be untyped. So when you want to const user = store.get('user'); and then see what properties user has — it’ll be unknown type. If you inspect your store type in your IDE, you’ll notice it’s a Record<> type with an unknown type passed in.

An easy workaround for this is “casting” the type:

const user = store.get("user") as UserData;

This forces Typescript to use the UserData type for user. This works fine and won’t trigger compilation errors — but ultimately adds extra work each time you grab data.

Instead, when creating the store we can pass in the types, and that’ll trickle down each time we use store.get(). This will make development much easier, and help Typescript validate your code deeper down the call stack.

Using electron-store

Let’s setup a new store with a defined schema and TS types. We’ll be creating a data store that keeps “install” data. To give you context, my app is a “Blender Launcher”, so I need to keep track of the different versions and locations of Blender installations. The data type reflects this, with properties for things like “version”.

Create a file to contain the store and your schema. This should live in near the “main” process file (usually src/main/main.ts):

// src/main/store.ts
import Store from "electron-store";
import { JSONSchemaType } from "ajv";
import { InstallData } from "renderer/common/types";

// Define your schema in TS
// This is essentially the shape/spec of your store
export type SchemaType = {
  installs: InstallData[];
};

// Define your schema per the ajv/JSON spec
// But you also need to create a mirror of that spec in TS
// And use the type here
const schema: JSONSchemaType<SchemaType> = {
  type: "object",
  properties: {
    installs: {
      type: "array",
      items: {
        type: "object",
        properties: {
          version: { type: "string" },
          path: { type: "string" },
          type: { type: "string" },
          tags: {
            type: "array",
            items: { type: "string" },
          },
        },
        required: ["path", "tags", "type", "version"],
      },
    },
  },
  required: ["installs"],
};

// We define the keys we'll be using to access the store
// This is basically the top-level properties in the object
// But electron-store supports dot notation, so feel free to set deeper keys

// We set the type like this so when we use `store.get()`
// It'll use the actual keys from store and infer the data type
export const STORE_KEYS: { [key: string]: keyof SchemaType } = {
  INSTALLS: "installs",
  // PREFERENCES: 'preferences',
  // PROJECTS: 'projects',
};

// Create new store with schema
// And make sure to pass in schema TS types
// If you don't do this, when you use `store.get/set`, the return type will be unknown.
// Not sure why this has lint error. But get/set methods return proper types so...
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const store = new Store<SchemaType>({ schema });

export default store;

Here are the types for references (and maybe better visualization of the data structure):

// types.ts
export const TAGS = {
  Android: "Android",
  MacOS: "MacOS",
  Windows: "Windows",
};
export type TagsEnum = keyof typeof TAGS;

export type InstallData = {
  /**
   * Version of app
   */
  version: string;
  /**
   * Path to Blender on computer
   */
  path: string;
  /**
   * Is it Release, Beta, etc?
   */
  type: string;
  /**
   * Maybe not needed? Maybe if versions have modules others don't?
   */
  tags: TagsEnum[];
};

Now that we have a store, we can use it to get() and set() some data. This happens inside the “main” Electron process, the place where we can use “server-side” APIs like NodeJS. In the main process, we create an IPC event handler. This will allow our client-side Electron (aka React) talk to our “server-side”:

import { app, BrowserWindow, shell, ipcMain, dialog } from "electron";
import { InstallData } from "renderer/common/types";
import store, { STORE_KEYS } from "./store";

ipcMain.handle("store:install", async (_, newInstall: InstallData) => {
  // We grab the previous data
  const prevInstalls = store.get(STORE_KEYS.INSTALLS);
  // And merge old data with new data
  // We also do a quick null check and pass empty array if so
  const result = store.set(STORE_KEYS.INSTALLS, [
    ...(prevInstalls || []),
    newInstall,
  ]);
  return result;
});

Then we can create a “bridge” from backend (”main”) to frontend (React) using the IPC:

// preload.js
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("electron", {
  storeInstall: async () => ipcRenderer.invoke("store:install"),
});

And inside our React app we can just:

const installData: InstallData = {
  version: "2.0",
  //etc
};
// Grab the method we passed into `window.electron`
// Try typing `window.electron.` and seeing what autocomplete pops up
const saveInstall = await window.electron.storeInstall(installData);

Want to see the code in it's entirety? Check out the project on Github here.

References

Table of Contents