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:
- database key used to persist the value
- initial value
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);
}
}