Some months ago I stumbled with this inherited new app in my company.
This app had some awesome and impressive features where I learned —and struggled ☠️— a ton of features that I didn't understand very well —or haven't heard of at all!
One of these cool features was using an IndexedDB for storage.
Honestly it took some time to understand where the hell the data was being stored. I thought that it was fetched everytime or cached with some sort of server magic that was beyond my reach. However, after diving deeper in the code I discovered 💡 that it was using an indexedDB to store the data in the browser, therefore saving a lot of unnecessary requests.
So what's an Indexed DB?
"IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files/blobs."
Key Concepts
- Asynchronous, therefore it won't block your main thread operations ⚡.
- A noSQL database, which makes it very flexible —and dangerous ☢️.
- Lets you access data offline.
- Can store a large amount of data (more than other web storage like localStorage or sessionStorage).
Okay, so once I had a better idea of what an IndexedDB was, I wanted to practice a bit with a simple implementation instead of having to painfully try it with the super complex codebase of my inherited real life application.
Implementation in a React App
This is a low-level API, therefore its implementation is kind of weird, but don't be scared, after some practice everything falls into its place. While there are some third app libraries to handle it —like Dexie—I wanted to try the raw API.
All the methods used here will return a Promise
, this way we'll be able to get a response in our component of what's happening in the DB. If you wish, you don't have to wait for a promise to be resolved, it's fine and actually how you'll see the implementation in most pages out there. I like the promise approach for better understanding what's going on 👀 but this approach of course will block the main thread until the promise is resolved.
Initializing the DB
First step, declare the db and open (or init) it in the user browser
// db.ts
let request: IDBOpenDBRequest;
let db: IDBDatabase;
let version = 1;
export interface User {
id: string;
name: string;
email: string;
}
export enum Stores {
Users = 'users',
}
export const initDB = (): Promise<boolean> => {
return new Promise((resolve) => {
// open the connection
request = indexedDB.open('myDB');
request.onupgradeneeded = () => {
db = request.result;
// if the data object store doesn't exist, create it
if (!db.objectStoreNames.contains(Stores.Users)) {
console.log('Creating users store');
db.createObjectStore(Stores.Users, { keyPath: 'id' });
}
// no need to resolve here
};
request.onsuccess = () => {
db = request.result;
version = db.version;
console.log('request.onsuccess - initDB', version);
resolve(true);
};
request.onerror = () => {
resolve(false);
};
});
};
This initDB
method will basically open the connection myDB
—we can have several DB's and this is the tag that identifies them— and then we'll attach two listeners to request
.
The onupgradeneeded
listener will only be fired when a) we create a new DB b) we update the version of the new connection with for example indexedDB.opn('myDB', version + 1)
.
The onsuccess
listener will be fired if nothing went wrong. Here's where we will usually resolve
our promise.
The onerror
is self explanatory. If our methods are correct we'll rarely hear from this listener. However, while building our app it will be very useful.
Now, our component will show this super simple UI
import { useState } from 'react';
import { initDB } from '../lib/db';
export default function Home() {
const [isDBReady, setIsDBReady] = useState<boolean>(false);
const handleInitDB = async () => {
const status = await initDB();
setIsDBReady(status);
};
return (
<main style={{ textAlign: 'center', marginTop: '3rem' }}>
<h1>IndexedDB</h1>
{!isDBReady ? (
<button onClick={handleInitDB}>Init DB</button>
) : (
<h2>DB is ready</h2>
)}
</main>
);
}
If we call the handleInitDB
the DB will be created and we'll be able to see it in our dev tools/application/IndexedDB tab:
Hooray! We have the DB up and running. The DB will persist even if the user refreshes or loses her connection 🔥.
Btw, the styling is up to you 😂.
Adding data
For the moment we have an empty DB. We are now going to add data to it.
export const addData = <T>(storeName: string, data: T): Promise<T|string|null> => {
return new Promise((resolve) => {
request = indexedDB.open('myDB', version);
request.onsuccess = () => {
console.log('request.onsuccess - addData', data);
db = request.result;
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
store.add(data);
resolve(data);
};
request.onerror = () => {
const error = request.error?.message
if (error) {
resolve(error);
} else {
resolve('Unknown error');
}
};
});
};
What's new here?
No onupgradeneeded
anymore. We don't make changes to the store version, therefore this listener is not required.
However, the onsuccess
listener changes. This time we'll write on the db using the transaction
method, which will lead us to be able to make use of the add
method, where we are finally passing our user data
.
Here Typescript helps us to avoid mistakes when passing our data and keeping our DB integrity intact by accepting a <T>
generic type, which for this case will be the User
interface.
Finally, in our onerror
I didn't want to lose much time for this, it will resolve a string
, but it could be an Exception
or something similar.
In our component we'll add this simple form to add users.
// Home component
const handleAddUser = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const target = e.target as typeof e.target & {
name: { value: string };
email: { value: string };
};
const name = target.name.value;
const email = target.email.value;
// we must pass an Id since it's our primary key declared in our createObjectStoreMethod { keyPath: 'id' }
const id = Date.now();
if (name.trim() === '' || email.trim() === '') {
alert('Please enter a valid name and email');
return;
}
try {
const res = await addData(Stores.Users, { name, email, id });
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Something went wrong');
}
}
};
// ...
{!isDBReady ? (
<button onClick={handleInitDB}>Init DB</button>
) : (
<>
<h2>DB is ready</h2>
{/* add user form */}
<form onSubmit={handleAddUser}>
<input type="text" name="name" placeholder="Name" />
<input type="email" name="email" placeholder="Email" />
<button type="submit">Add User</button>
</form>
{error && <p style={{ color: 'red' }}>{error}</p>}
</>
)}
If we add the user and go to our application (don't forget to refresh the database for the stale data) we'll see our latest input available in our IDB.
Retrieving data
Ok, we are almost there.
Now we'll get the store data so we can display it. Let's declare a method to get all the data from our chosen store.
export const getStoreData = <T>(storeName: Stores): Promise<T[]> => {
return new Promise((resolve) => {
request = indexedDB.open('myDB');
request.onsuccess = () => {
console.log('request.onsuccess - getAllData');
db = request.result;
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const res = store.getAll();
res.onsuccess = () => {
resolve(res.result);
};
};
});
};
And in our jsx component
// ourComponent.tsx
const [users, setUsers] = useState<User[]|[]>([]);
// declare this async method
const handleGetUsers = async () => {
const users = await getStoreData<User>(Stores.Users);
setUsers(users);
};
// jsx
return (
// ... rest
{users.length > 0 && (
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.id}</td>
</tr>
))}
</tbody>
</table>
)}
// rest
)
And et voilà! We see our data in the table and this data will persist in the browser 👏.
Finally, to make this more dynamic we can make any of theses processes without all these buttons since we receive promises:
const handleAddUser = async (e: React.FormEvent<HTMLFormElement>) => {
// ...
try {
const res = await addData(Stores.Users, { name, email, id });
// refetch users after creating data
handleGetUsers();
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Something went wrong');
}
}
};
Deleting rows
Finally, we'll understand how to delete data from the DB. This is quite easy, however, we'll have to have in mind which is our Key Path —a.k.a. Unique identifier or Primary Key— for the entries of the selected store. In this case we declared that the id
will be that identifier
// db.ts/initDb
db.createObjectStore(Stores.Users, { keyPath: 'id' });
You can also verify it directly by inspecting your store in the dev tools
For more advanced keys you can make use of other Key Paths, however we won't cover those cases here, so using the id as KP is the expected pick.
The method to delete the selected row will accept a storeName ('users' in this case), and the id as parameters.
export const deleteData = (storeName: string, key: string): Promise<boolean> => {
return new Promise((resolve) => {
// again open the connection
request = indexedDB.open('myDB', version);
request.onsuccess = () => {
console.log('request.onsuccess - deleteData', key);
db = request.result;
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const res = store.delete(key);
// add listeners that will resolve the Promise
res.onsuccess = () => {
resolve(true);
};
res.onerror = () => {
resolve(false);
}
};
});
};
Promises should always be handled with a try/catch
block.
And in the component a handler to remove users, and of course a button to pick the desired element in the table
const handleRemoveUser = async (id: string) => {
try {
await deleteData(Stores.Users, 'foo');
// refetch users after deleting data
handleGetUsers();
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Something went wrong deleting the user');
}
}
};
// ...
return (
// ...
{users.length > 0 && (
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>ID</th>
// header
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.id}</td>
// here the button
<td>
<button onClick={() => handleRemoveUser(user.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
);
}
And the element should be gone after clicking the Delete button 🪄.
Conclusion
To be honest, I think that this API is weird and it feels a lack of implementation examples (for React at least) and documentation —besides a few great ones of course— and it should IMO be used only in a few specific cases. It's always nice to have options, and if it's your case, go ahead, I've witnessed a real production use case in an app that has thousands of users, and I must confess that the IDB has been the feature that has brought me the least trouble. This tool has been out there for several years already, so it's stable enough to be safely used.
If you want to check the full code, here's the repo.
Sources
Photo from Catgirlmutant on Unsplash
Extra
Top comments (3)
Awesome 🖤
🔥🔥🔥🔥
At first I was lot but from the set and delete part I began to understand how it works
Thanks very much