How to store data client-side with IndexedDB
Shailesh Vasandani
Posted on January 22, 2021
Imagine a calculus exam where you had to do all the calculations in your head. It's technically possible, but there's absolutely no reason to do it. The same principle applies to storing things in the browser.
Today, there are a number of widely implemented technologies for client-side storage. We have cookies, the Web Storage API, and IndexedDB. While it's entirely possible to write a fully functioning web application without worrying about any of these, you shouldn't. So how do you use them? Well each of them has a use case that they're best suited to.
A quick overview of browser storage
Cookies
Cookies, being sent on basically every request, are best used for short bits of data. The big advantage of cookies is that servers can set them directly by using the Set-Cookie
header, no JavaScript required. On any subsequent requests, the client will then send a Cookie
header with all previously set cookies. The downside of this is that large cookies can seriously slow down requests. That's where the next two technologies come in.
Web Storage
The Web Storage API is composed of two similar stores — localStorage
and sessionStorage
. They both have the same interface, but the latter lasts only while the browsing session is active. The former persists as long as there is available memory. This memory limit is both its largest advantage and disadvantage.
Because these values aren't sent along with every request, it's possible to store large amounts of data in them without affecting performance. However, "large" is relative, and the storage limit can vary wildly across browsers. A good rule of thumb is to store no more than 5 MB for your entire site. That limit isn't ideal, and if you need to store more than that, you're probably going to need the third and final API.
IndexedDB
IndexedDB, one might argue, is criminally underrated. Despite being supported across basically every browser, it's nowhere near as popular as the other two. It's not sent with every request like cookies are, and it doesn't have the arbitrary limits of Web Storage. So what gives?
The reason IndexedDB is not very popular is, it turns out, that it's an absolute pain to use. Instead of using Promises
or async/await
, you need to define success and error handlers manually. Many libraries encapsulate this functionality, but they can often be overkill. If all you need is to save and load data, you can write everything you need yourself.
Wrapping IndexedDB neatly
While there are lots of ways to interface with IndexedDB, what I'll be describing is my personal, opinionated way of doing so. This code works for one database and one table, but should be easily modified to fit other use cases. Before we jump into code, let's make a quick list of what requirements we need.
1. Ideally, it's some sort of class or object that we can import and export.
2. Each "object" should represent one database
and table
only.
3. Much like a CRUD API, we need methods to read, save, and delete key-value pairs.
That seems simple enough. Just a side note - we'll be using ES6 class
syntax here, but you can modify that as you wish. You don't even need to use a class if you're only using it for one file. Now let's get started.
Some boilerplate
We know essentially what methods we need, so we can stub those out and make sure all the functions make sense. That way, it's easier to code and test (which I didn't do because it was for a personal project, but I really should get onto that).
Hey, it looks like you're on a slightly narrower screen. The code blocks below might not look too good, but the rest of the article should be fine. You can hop on a wider screen if you want to follow along. I'm not going anywhere (promise).
class DB {
constructor(dbName="testDb", storeName="testStore", version=1) {
this._config = {
dbName,
storeName,
version
};
}
set _config(obj) {
console.error("Only one config per DB please");
}
read(key) {
// TODO
}
delete(key) {
// TODO
}
save(key, value) {
// TODO
}
}
Here we've set up some boilerplate that has all of our functions, and a nice constant configuration. The setter
around _config
ensures that the configuration can't be changed at any point. That will help both debug any errors and prevent them from happening in the first place.
With the boilerplate all done, it's time to move on to the interesting part. Let's see what we can do with IndexedDB.
Reading from the database
Even though IndexedDB doesn't use Promises
, we'll be wrapping all of our functions in them so that we can work asynchronously. In a sense, the code we'll be writing will help bridge the gap between IndexedDB and more modern ways of writing JavaScript. In our read
function, let's wrap everything in a new Promise
:
read(key) {
return new Promise((resolve, reject) => {
// TODO
});
}
If and when we get the value from the database, we'll use the resolve
argument to pass it along the Promise
chain. That means we can do something like this somewhere else in the code:
db = new DB();
db.read('testKey')
.then(value => { console.log(value) })
.catch(err => { console.error(err) });`
Now that we have that set up, let's look at what we need to do to open up the connection. To open the actual database, all we need to do is call the open
method of the window.indexedDB
object. We're also going to need to handle three different cases — if there's an error, if the operation succeeds, and if we need an upgrade. We'll stub those out for now. What we have so far looks like this:
read(key) {
return new Promise((resolve, reject) => {
let dbRequest = window.indexedDB.open(dbConfig.dbName);
dbRequest.onerror = (e) => {
// TODO
};
dbRequest.onupgradeneeded = (e) => {
// TODO
};
dbRequest.onsuccess = (e) => {
// TODO
};
});
}
If the open
errors out, we can simply reject
it with a useful error message:
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
For the second handler, onupgradeneeded
, we don't need to do much. This handler is only called when the version
we provide in the constructor doesn't already exist. If the version of the database doesn't exist, there's nothing to read from. Thus, all we have to do is abort the transaction and reject the Promise
:
dbRequest.onupgradeneeded = (e) => {
e.target.transaction.abort();
reject(Error("Database version not found."));
};
That leaves us with the third and final handler, for the success state. This is where we'll be doing the actual reading. I glossed over the transaction in the previous handler, but it's worth spending the time to go over now. Because IndexedDB is a NoSQL database, reads and writes are performed in transactions. These are just records of the different operations being performed on the database, and can be reverted or reordered in different ways. When we aborted the transaction above, all we did was tell the computer to cancel any pending changes.
Now that we have the database though, we'll need to do more with our transaction. First, let's get the actual database:
let database = e.target.result;
Now that we have the database, we can get the transaction and the store consecutively.
let transaction = database.transaction([ _config.storeName ]);
let objectStore = transaction.objectStore(_config.storeName);
The first line creates a new transaction and declares its scope. That is, it tells the database that it'll only be working with one store, or table. The second gets the store and assigns it to a variable.
With that variable, we can finally do what we set out to. We can call the get
method of that store to get the value associated with the key.
let objectRequest = objectStore.get(key);
We're just about done here. All that's left to do is to take care of the error and success handlers. One important thing to note is that we're checking to see if the actual result exists. If it doesn't we'll throw an error as well:
objectRequest.onerror = (e) => {
reject(Error("Error while getting."));
};
objectRequest.onsuccess = (e) => {
if (objectRequest.result) {
resolve(objectRequest.result);
} else reject(Error("Key not found."));
};
And with that done, here's our read
function in its entirety:
read(key) {
return new Promise((resolve, reject) => {
let dbRequest = window.indexedDB.open(_config.dbName);
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
dbRequest.onupgradeneeded = (e) => {
e.target.transaction.abort();
reject(Error("Database version not found."));
};
dbRequest.onsuccess = (e) => {
let database = e.target.result;
let transaction = database.transaction([ _config.storeName ], 'readwrite');
let objectStore = transaction.objectStore(_config.storeName);
let objectRequest = objectStore.get(key);
objectRequest.onerror = (e) => {
reject(Error("Error while getting."));
};
objectRequest.onsuccess = (e) => {
if (objectRequest.result) {
resolve(objectRequest.result);
} else reject(Error("Key not found."));
};
};
});
}
Deleting from the database
The delete
function goes through a lot of the same steps. Here's the whole function:
delete(key) {
return new Promise((resolve, reject) => {
let dbRequest = indexedDB.open(_config.dbName);
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
dbRequest.onupgradeneeded = (e) => {
e.target.transaction.abort();
reject(Error("Database version not found."));
};
dbRequest.onsuccess = (e) => {
let database = e.target.result;
let transaction = database.transaction([ _config.storeName ], 'readwrite');
let objectStore = transaction.objectStore(_config.storeName);
let objectRequest = objectStore.delete(key);
objectRequest.onerror = (e) => {
reject(Error("Couldn't delete key."));
};
objectRequest.onsuccess = (e) => {
resolve("Deleted key successfully.");
};
};
});
}
You'll notice two differences here. First, we're calling delete
on the objectStore
. Second, the success handler resolves right away. Other than those two, the code is essentially identical. This is the same for the third and final function.
Saving to the database
Again, because it's so similar, here's the entirety of the save
function:
save(key, value) {
return new Promise((resolve, reject) => {
let dbRequest = indexedDB.open(dbConfig.dbName);
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
dbRequest.onupgradeneeded = (e) => {
let database = e.target.result;
let objectStore = database.createObjectStore(_config.storeName);
};
dbRequest.onsuccess = (e) => {
let database = e.target.result;
let transaction = database.transaction([ _config.storeName ], 'readwrite');
let objectStore = transaction.objectStore(_config.storeName);
let objectRequest = objectStore.put(value, key); // Overwrite if exists
objectRequest.onerror = (e) => {
reject(Error("Error while saving."));
};
objectRequest.onsuccess = (e) => {
resolve("Saved data successfully.");
};
};
});
}
There are three differences here. The first is that the onupgradeneeded
handler needs to be filled in. That makes sense, since setting values in a new version of the database should be supported. In it, we simply create the objectStore
using the aptly named createObjectStore
method. The second difference is that we're using the put
method of the objectStore
to save the value instead of reading or deleting it. The final difference is that, like the delete
method, the success handler resolves immediately.
With all that done, here's what it looks like all put together:
class DB {
constructor(dbName="testDb", storeName="testStore", version=1) {
this._config = {
dbName,
storeName,
version
};
}
set _config(obj) {
console.error("Only one config per DB please");
}
read(key) {
return new Promise((resolve, reject) => {
let dbRequest = window.indexedDB.open(_config.dbName);
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
dbRequest.onupgradeneeded = (e) => {
e.target.transaction.abort();
reject(Error("Database version not found."));
};
dbRequest.onsuccess = (e) => {
let database = e.target.result;
let transaction = database.transaction([ _config.storeName ], 'readwrite');
let objectStore = transaction.objectStore(_config.storeName);
let objectRequest = objectStore.get(key);
objectRequest.onerror = (e) => {
reject(Error("Error while getting."));
};
objectRequest.onsuccess = (e) => {
if (objectRequest.result) {
resolve(objectRequest.result);
} else reject(Error("Key not found."));
};
};
});
}
delete(key) {
return new Promise((resolve, reject) => {
let dbRequest = indexedDB.open(_config.dbName);
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
dbRequest.onupgradeneeded = (e) => {
e.target.transaction.abort();
reject(Error("Database version not found."));
};
dbRequest.onsuccess = (e) => {
let database = e.target.result;
let transaction = database.transaction([ _config.storeName ], 'readwrite');
let objectStore = transaction.objectStore(_config.storeName);
let objectRequest = objectStore.delete(key);
objectRequest.onerror = (e) => {
reject(Error("Couldn't delete key."));
};
objectRequest.onsuccess = (e) => {
resolve("Deleted key successfully.");
};
};
});
}
save(key, value) {
return new Promise((resolve, reject) => {
let dbRequest = indexedDB.open(dbConfig.dbName);
dbRequest.onerror = (e) => {
reject(Error("Couldn't open database."));
};
dbRequest.onupgradeneeded = (e) => {
let database = e.target.result;
let objectStore = database.createObjectStore(_config.storeName);
};
dbRequest.onsuccess = (e) => {
let database = e.target.result;
let transaction = database.transaction([ _config.storeName ], 'readwrite');
let objectStore = transaction.objectStore(_config.storeName);
let objectRequest = objectStore.put(value, key); // Overwrite if exists
objectRequest.onerror = (e) => {
reject(Error("Error while saving."));
};
objectRequest.onsuccess = (e) => {
resolve("Saved data successfully.");
};
};
});
}
}
To use it, all you'd have to do is create a new DB
object and call the specified methods. For example:
const db = new DB();
db.save('testKey', 12)
.then(() => {
db.get('testKey').then(console.log); // -> prints "12"
})
Some finishing touches
If you want to use it in another file, just add an export statement to the end:
export default DB;
Then, import it in the new script (making sure everything supports modules), and call it:
import DB from './db';
Then, use it as is.
As always, don't forget to follow me for more content like this. I'm currently writing on dev.to and Medium, and your support on either platform would be very much appreciated. I also have a membership set up, where you can get early previews of articles and exclusive access to a whole bunch of resources. Also, if you've particularly enjoyed this post, consider supporting me by buying me a coffee. Until next time!
Posted on January 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 4, 2023