Bottom-up Browser Storage Management
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.