Home
Software
Writings
Persisted Svelte store using IndexedDB
programming svelte
I’m working on notepad2 for web and I need a history of opened files that persists across browser session.
Since I’m using Svelte, having it available as a store makes sense.
This article describes how to implement a Svelte store whose values are persisted in IndexedDB.

What is Svelte store?

The simplest Svelte store is an object with subscribe function.
You call subscribe to provide a callback function that will be called when the value changes:
let foo = makeSvelteStoreFoo();
foo.subscribe((v) => {
    console.log("New value of foo is:", v);
})
Svelte provides a nicer, less verbose syntax to use stores:
let foo = makeSvelteStoreFoo();
console.log("Value of foo is:", $foo);
Under the covers Svelte calls subscribe and makes it so $foo returns the latest value.
The $foo value is reactive so if you use it in a component like <div>{$foo}</div>, it’ll be automatically re-rendered when the value changes.

Creating a store

Typically stores are created by a function returning a store object:
function makeSvelteStoreFoo() {
    function subscribe(subscriber) {
        ...
        function unsubscribe() { ... }
        return unsubscribe;
    }
    return {subscriber: subscriber};
}
Function subscribe returns unsubscribe function to call when you no longer need to observe the changes to the value.
Svelte does that for you if you use $foo syntax.

Writable store

The above store is read-only: it provides values but you can’t change it.
To make the store writable you also need to return set(newValue) function.
function makeSvelteStoreFoo() {
    function subscribe(subscriber) { ... }

    function set(newValuse) { ... }
    return {subscriber: subscriber, set: set};
}

Global store vs. multiple instances of store

Some stores can have multiple instances. In that case you’ll export function makeSvelteStoreFoo() and call it to get a new instance of the store.
Some stores are global and only should have one shared instance. In that case you’ll only export the single global instance:
function makeSvelteStoreFoo() { ... }

export let foo = makeSvelteStoreFoo();

Persisted store with values backed by IndexedDB

If you want your store to survive across browser sessions, you need to persist it somehow.
I like IndexedDB and lightweight idb helper library.
IndexedDB supports more data types than localStorage, which only handles strings.
I create a single database for all my key-value needs and have each Svelte store persist values under unique database key.
Here’s a structure of such store:
function makeStore() {
  function set(v) {}

  function subscribe(subscriber) {
    function unsubscribe() {}
    return unsubscribe;
  }

  return { set, subscribe };
}
Let’s fill out the implementation.
First, we need a db for backing store:
import { KV } from "../dbutil";
const db = new KV("np2store", "keyval");
This is my global database for all key-value data, including my persisted Svelte store. See below for implementation of KV.
We use a unique database key for each store.
We have a variable that keeps the value in memory, which is more efficient that re-reading from database.
When creating a store we read the initial value from the database:
function makeStore() {
  const dbKey = "browse-folders";
  let curr = [];

  db.get(dbKey).then((v) => {
    curr = v || [];
    broadcastValue();
  });
}
The initial value is [] because this store happens to store an array so we want the right “unset” value.
broadcastValue informs all subscribers about the change in value of the store:
function makeStore() {
  const subscribers = new Set();

  function broadcastValue() {
    subscribers.forEach((cb) => cb(curr));
  }
}
We store subscriber callback functions in a Set. Here’s how subscribe() is implemented:
function makeStore() {
  const subscribers = new Set();

  function subscribe(subscriber) {
    subscriber(curr);
    subscribers.add(subscriber);
    function unsubscribe() {
      subscribers.delete(subscriber);
    }
    return unsubscribe;
  }
}
The contract for Svelte store is that upon subscription we need to synchronously call susbscriber callback to immediately provide the current value.
And finally the set(newValue) function:
function makeStore() {
  function set(v) {
    curr = v;
    broadcastValue();
    db.set(dbKey, v);
  }
}

Changes in another browser tab

What if the value is changed in another browser tab?
If you open the same website twice in different tabs they both write to the same underlying database but they don’t know about each other changes and would over-write data changed by the other instance.
Turns out localStorage has a feature that allows us to fix that: we can monitor all changes to localStorage even if they are made by an instance in another tab.
I use unique localStorage value to notify all tabs about changes in our store.
function makeStore() {
  const dbKey = "browse-folders";
  const lsKey = "store-notify:" + dbKey;

  function getCurrentValue() {
    db.get(dbKey).then((v) => {
      curr = v || [];
      broadcastValue();
    });
  }

  getCurrentValue();

  /**
   * @param {StorageEvent} event
   */
  function storageChanged(event) {
    if (event.storageArea === localStorage && event.key === lsKey) {
      getCurrentValue();
    }
  }
  window.addEventListener("storage", storageChanged, false);

  function set(v) {
    curr = v;
    broadcastValue();
    db.set(dbKey, v).then((v) => {
        // notify other browser tabs
        const v = +localStorage.getItem(lsKey) || 0;
        localStorage.setItem(lsKey, `${v + 1}`);
    });
  }
}
Let’s dissect this tricky line:
const v = +localStorage.getItem(lsKey) || 0;
To ensure the value changes, we implement a numeric counter. localStorage stores strings, so we need to convert string => number on reading and number => string on writing.
+foo converts whatever foo is to a number. If foo is a string "42", +foo returns number 42.
For non-number strings it returns NaN (a special number value indicating Not A Number).
We do NaN || 0 to convert NaN to 0.

Factory function to easily create stores

You could now piece together the whole implementation and create your own stores based on this template.
Then you would notice that they share most of the code. The differences are:
We can abstract this into a factory function that creates the store for a given key and initial value.
function makeIndexedDBStore(dbKey, initialValue, crossTabSync) { ... }

export foo = makeIndexedDBStore("foo", 0, false);
export bar = makeIndexedDBStore("bar", [], true);
Here’s our makeIndexedDBStore():
/**
 * Create a generic Svelte store persisted in IndexedDB
 * @param {string} dbKey unique IndexedDB key for storing this value
 * @param {any} initialValue
 * @param {boolean} crossTab if true, changes are visible in other browser tabs (windows)
 * @returns {any}
 */
function makeIndexedDBStore(dbKey, initialValue, crossTab) {
  function makeStoreMaker(dbKey, initialValue, crossTab) {
    const lsKey = "store-notify:" + dbKey;
    let curr = initialValue;
    const subscribers = new Set();

    function getCurrentValue() {
      db.get(dbKey).then((v) => {
        curr = v || [];
        subscribers.forEach((cb) => cb(curr));
      });
    }

    getCurrentValue();

    /**
     * @param {StorageEvent} event
     */
    function storageChanged(event) {
      if (event.storageArea === localStorage && event.key === lsKey) {
        getCurrentValue();
      }
    }
    if (crossTab) {
      window.addEventListener("storage", storageChanged, false);
    }

    function set(v) {
      curr = v;
      subscribers.forEach((cb) => cb(curr));
      db.set(dbKey, v).then((v) => {
        if (crossTab) {
          const n = +localStorage.getItem(lsKey) || 0;
          localStorage.setItem(lsKey, `${n + 1}`);
        }
      });
    }

    /**
     * @param {Function} subscriber
     */
    function subscribe(subscriber) {
      subscriber(curr);
      subscribers.add(subscriber);
      function unsubscribe() {
        subscribers.delete(subscriber);
      }
      return unsubscribe;
    }

    return { set, subscribe };
  }
  return makeStoreMaker(dbKey, initialValue, crossTab);
}

KV store

Here’s a helper class for key-value store using idb library.
import { openDB } from "idb";

export class KV {
  dbName;
  storeName;
  dbPromise;

  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.dbPromise = openDB(dbName, 1, {
      upgrade(db) {
        db.createObjectStore(storeName);
      },
    });
  }

  async get(key) {
    return (await this.dbPromise).get(this.storeName, key);
  }
  async set(key, val) {
    return (await this.dbPromise).put(this.storeName, val, key);
  }
  async del(key) {
    return (await this.dbPromise).delete(this.storeName, key);
  }
  async clear() {
    return (await this.dbPromise).clear(this.storeName);
  }
  async keys() {
    return (await this.dbPromise).getAllKeys(this.storeName);
  }
}
Written on Mar 9 2023. Topics: programming, svelte.
home
Found a mistake, have a comment? Let me know.

Feedback about page:

Feedback:
Optional: your email if you want me to get back to you: