Skip to main content

Bottom-up Browser Storage Management

· 7 min read
xoron
positive-intentions

I'm working on creating something I can call "functional web components".

Following the previous article explaining how we can create asynchronous bottom-up state management, we have the basics to put together an state management system. State management solution in apps typically have ways to persist data. I wanted to explore if there are any benefits to define and manage state in web components with a bottom-up approach. I wanted to see if it could give a greater flexibility in developing a UI and not having to worry about persisted storage management.

To further enhance our functional web component UI library, we can add the ability to store state in the browser. A common feature used by web apps is browser storage, which includes cookies, local storage, and session storage.

In the previous article, we created an event-listener-based state management system. This system allows us to deterministically generate an identifier for each state, enabling components to subscribe to updates for specific states.

In this article, we will use the ability to deterministically generate keys for key-value storage. We will utilize the IndexedDB API to store our state.


Lets consider what we would need to do to store our state in indexedDB. Some high-level considerations are:

  • The ability to updatewrite the state to indexedDB
  • The ability to read the state from indexedDB
  • The ability to load the state from indexedDB on mount

Creating a Storage Manager

Lets start creating a storage manager class to help simplify interfacing with the storage on a browser. We will want to start off by connecting-to or creating a database.

let db;
const databaseName = "DimDatabase";
const objectStoreName = "DimStore";

class StorageManager {
constructor() {
this.openDatabase();
}

async openDatabase() {
return new Promise((resolve, reject) => {
let request = indexedDB.open(databaseName, 1);

request.onupgradeneeded = function (event) {
db = event.target.result;
if (!db.objectStoreNames.contains(objectStoreName)) {
db.createObjectStore(objectStoreName, { keyPath: "id" });
}
};

request.onsuccess = function (event) {
db = event.target.result;
resolve(db);
};

request.onerror = function (event) {
reject("Error opening database: " + event.target.error);
};
});
}
}

export default StorageManager;

We have created a StorageManager class that opens a database upon creation. In this example, we are using the DimDatabase database with the DimStore object store, utilizing the id field as the key for the object store.


Writing to the Database

Now that we have a database, we can start implementing functionality to write to it.


let db;
const objectStoreName = "DimStore";

class StorageManager {

...

async writeValue(id, value) {
return new Promise((resolve, reject) => {
let transaction = db.transaction([objectStoreName], "readwrite");
let objectStore = transaction.objectStore(objectStoreName);

const valueAsBase64 = btoa(JSON.stringify({ payload: value }));
let request = objectStore.put({ id: id, value: valueAsBase64 });

request.onsuccess = function (event) {
resolve("Value written successfully");
};

request.onerror = function (event) {
reject("Error writing value: " + event.target.error);
};
});
}
}

export default StorageManager;

Here we have added a writeValue function that writes a value to the database. We are encoding the value as base64 before writing it to ensure the data is serialized before storing. We can inspect the database in the browser's developer tools to see the stored data.


Reading from the Database

Now that we can write to the database, we can implement a function to read from it.


let db;
const objectStoreName = "DimStore";

class StorageManager {

...

async readValue(id) {
return new Promise((resolve, reject) => {
let transaction = db.transaction([objectStoreName], "readonly");
let objectStore = transaction.objectStore(objectStoreName);
let request = objectStore.get(id);

request.onsuccess = function (event) {
if (request.result) {
const value = JSON.parse(atob(request.result.value)).payload;
resolve({ value });
} else {
resolve(null);
}
};

request.onerror = function (event) {
reject("Error reading value: " + event.target.error);
};
});
}
}

export default StorageManager;

We have added a readValue function that reads a value from the database. We are decoding the value from base64 before returning it, and we are returning the value as an object with a value key.


Loading from the Database

Now that we can read from the database, we have the parts needed to implement the loadFromDatabase function to use when we mount the component.

import { debouncedDispatcher } from './mini-lit.js';

class StorageManager {

...

loadFromDatabase = (store) => {
const traverse = (obj, path) => {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === "object" && obj[key].length === undefined) {
traverse(obj[key], `${path}${key}.`);
} else {
this
.readValue(`${path}${key}`, obj[key])
.then((response) => {
if (response) {
// throw new Error("Value not found in database");
debouncedDispatcher(`${path}${key}`, response.value);
}
})
.catch(console.error);
}
});
};

traverse(store, "");
};
}

export default StorageManager;

Similarly to how we implemented the createListeners function in the AsynchronousStateManager, we are traversing the store object and reading the values from the database. We are using the debouncedDispatcher function to update the state. We are also using the path to determine the key for the value in the database. Let's create the debouncedDispatcher function.

function createDebouncedEventDispatcher(delay) {
const timeoutIds = {};

return (eventName, value) => {
if (timeoutIds[eventName] !== undefined) {
clearTimeout(timeoutIds[eventName]);
}

timeoutIds[eventName] = window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent(eventName, {
detail: value,
})
);
timeoutIds[eventName] = undefined;
}, delay);
};
}

export const debouncedDispatcher = createDebouncedEventDispatcher(10);

Here we have created a createDebouncedEventDispatcher function that creates a debounced event dispatcher. For each eventName (which would be the path to the value in the store), we are dispatching a custom event with the value as the detail. We are using a delay of 10ms. The timeoutIds object keeps track of the timeouts for each event. We clear the timeout if it exists before setting a new one, then dispatch a custom event with the event name and the value as the detail.


Putting it all together

We can now update the useStore hook to how we expect it to work:

export const useStore = (store) => {
const [randomId] = useState(crypto.getRandomValues(new Uint8Array(8)));

asyncronousStateManager.createListeners(store, randomId);

useEffect(() => {
storageManager.loadFromDatabase(store);
return () => {
asyncronousStateManager.removeListeners(randomId);
};
}, []);

return store;
};

We are now loading the state from the database when the component mounts. This will trigger the custom event dispatch with the loaded value from the database for any components that are subscribed to changes.

We have now created a simple storage manager that can be used to store state in IndexedDB. We have also updated the useStore hook to load the state from the database when the component mounts.


Demo


Conclusion

We have created a simple storage manager that can be used to store state in IndexedDB. We have also updated the useStore hook to load the state from the database when the component mounts. This will trigger the custom event dispatch with the loaded value from the database for any components that are subscribed to changes.

I don't recommend using this approach to create any production-ready applications. This is more of an experiment to see if we can create a bottom-up browser-storage management system.

I'm sure there are more changes that can be made to make it better. I would love to hear your thoughts on this.

The Dim series:

  1. Functional Web Components
  2. Funtional Todo App
  3. Async State Management
  4. Bottom-up Browser Storage