In 2018 and 2019, I wrote a three-part series on how to make a small web app in IndexedDB, which you can see at the links below:
- Part 1: Build a basic app with IndexedDB
- Part 2: Testing IndexedDB code with Jest
- Part 3: Using promises in IndexedDB code
Recently, I have tried out a popular library called Dexie.js. I found that it really makes IndexedDB code a lot more straightforward and fast to write with a beautiful abstraction over the built-in IndexedDB API, so I'd like to show you how you would re-create the database layer of the app from my previous tutorial using Dexie!
If you haven't read my other tutorials, reading them helps but isn't strictly necessary for following along in this one, so no need to backtrack. But as we'll discuss at the end, core IndexedDB concepts are still worth knowing if you're working with Dexie, as Dexie is an abstraction over IndexedDB.
All the code from this tutorial can be found on GitHub here.
Review of our app and its database interactions
The app we were making is a sticky note app, where you can write sticky notes and display them in forward or reverse chronological order. So the database interactions we had to implement are:
- ๐๏ธ Set up the database, creating an IndexedDB object store for our sticky notes, with an index on the timestamp of storing it
- ๐ Adding a sticky note to the object store
- ๐ Retrieving our sticky notes, in forward or reverse order so we can display them
The app looks like this:
Making our skeleton Dexie class
Let's start by making a file called db.js
. When I make the database layer of something, I prefer to wrap the logic of all database interactions in a single class so it's all in one place. Here's what a skeleton of that class will look like:
let { Dexie } = require('dexie');
// Database handles all database interactions for the web app.
class Database extends Dexie {
// our Database constructor sets up an IndexedDB database with a
// sticky notes object store, titled "notes".
constructor() {}
// addStickyNote makes a sticky note object from the text passed
// in and stores it in the database. Returns a promise that
// resolves on success.
addStickyNote(message) {}
// getNotes retrieves all sticky notes from the IndexedDB
// database, in forward or reverse chronological order. Returns
// a promise that resolves on success, containing our array of
// sticky notes.
getNotes(reverseOrder) {}
}
module.exports = Database;
As you can see, we have a class with three methods: a constructor for setting up the database with a sticky notes object store, addStickyNote
for storing a sticky note in the notes object store, and getNotes
for retrieving the sticky notes.
Even just from the skeleton class, we already can notice a couple things about Dexie:
class Database extends Dexie {
constructor() {}
// more code below
}
First of all, I made the class extend the Dexie
class. Dexie
is the main class of the database library, and it represents a connection to an IndexedDB database.
// addStickyNote makes a sticky note object from the text passed
// in and stores it in the database. Returns a promise that
// resolves on success.
addStickyNote(message) {}
The other thing worth noticing is that I had both the addStickyNote
and getNotes
methods return promises. In part 3 of this series, we put a fair amount of effort into wrapping IndexedDB's callback API in a promise-based abstraction to make it easier to work with. In Dexie, all the database interactions return promises, and that means out of the box, they work well with async/await
patterns.
Writing a database constructor
Just like with setting up a database in plain IndexedDB, in our database constructor we want to create the database, give it an object store, and define indices on that store. Here's what that would look like with Dexie:
constructor() {
super('my_db');
this.version(1).stores({
notes: '++id,timestamp',
});
this.notes = this.table('notes');
}
Just three statements to make everything, and unlike in the setupDB
function from the previous tutorials, we aren't thinking at all about IndexedDB "open DB" requests, or onupgradeneeded
callbacks. Dexie handles all that logic for us behind the scenes! Let's take a look at what each statement does:
super('my_db');
In the first statement, we run the Dexie
constructor, passing in the name of our database. By doing this, we now have a database created with the name "my_db".
this.version(1).stores({
notes: '++id,timestamp',
});
In the second statement, we get version 1
of the database schema with the version method, and then make our object stores using the stores method.
The object we pass into stores
defines the object stores we want to make; there's one store made for each key in that object, so we have a notes
store made with the notes
key.
We define the indices on each store using the comma-separated string values on the object:
- The
++id
string makes the ID of a sticky note the object store's auto-incrementing primary key, similar to passing{ autoIncrement: true }
into the built-in IndexedDBcreateObjectStore
method. - We also make an index on
timestamp
so we can query for sticky notes in chronological order.
You can see the other syntax for making indices for your IndexedDB tables in the documentation for the Version.stores method.
this.notes = this.table('notes');
Finally, totally optionally, we can use the Dexie.table method to get a Dexie Table
object, which is a class that represents our object store. This way, we can do interactions with the notes
object store using methods like this.notes.add()
. I like doing that to have the database table represented as a field on the class, especially if I'm using TypeScript.
We've got our database constructor, so now we've got a big implementation of addNotes
to write.
Adding a sticky note to the database in Dexie
In the built-in IndexedDB API, adding an item to an object store would involve:
- Starting a
readwrite
transaction on thenotes
object store so no other interactions with that store can happen at the same time, and then retrieving our object store withIDBTransaction.objectStore
. - Calling
IDBObjectStore.add
to get an IndexedDB request to add the sticky note. - Waiting for that to succeed with the request's
onsuccess
callback.
Let's see what that all looks like in Dexie:
addStickyNote(message) {
return this.notes.add({ text: message, timestamp: new Date() });
}
Just a single statement of code, and we didn't need to think about IndexedDB transactions or requests because when we call Table.add, Dexie handles starting the transaction and making the request behind the scenes!
Table.add
returns a promise that resolves when the underlying IndexedDB request succeeds, so that means in our web app, we can use promise chaining or the async/await pattern like this:
function submitNote() {
let message = document.getElementById('newmessage');
db.addStickyNote(message.value).then(getAndDisplayNotes);
message.value = '';
}
we put getAndDisplayNotes
in the function that we run as the then
of the promise that addStickyNote
returns.
By the way, while Table.add
does abstract away transactions, that's not to say IndexedDB transactions can't be more explicitly created in Dexie when we need them. If we want to do something like store items in two object stores at the same time, we could use the Dexie.transaction method.
Now let's see how we can query for sticky notes from our object store!
Retrieving sticky notes
In the built-in IndexedDB API, if we wanted to retrieve all the items from our notes
object store, we would do the following:
- Start a
readonly
transaction on ournotes
object store. - Retrieve the object store with
IDBTransaction.getObjectStore
. - Open a cursor for our query we want to make.
- Iterate over each item in the store that matches our query.
With Dexie, we can do this querying in just one statement that's got a slick chaining API!
getNotes(reverseOrder) {
return reverseOrder ?
this.notes.orderBy('timestamp').reverse().toArray() :
this.notes.orderBy('timestamp').toArray();
}
Let's break this down:
- We select which index we want to sort results with using Table.orderBy; in this case we want to order our results by their timetsamps.
- If
reverseOrder
is true, then we can use the Collection.reverse method, so we get the newest sticky notes first. - Finally,
toArray
returns a promise that resolves when our query is successfully run. In the promise'sthen
method, you can then make use of our array of sticky notes.
That's not even close to all the ways you can modify a query with Dexie though. Let's say we only wanted sticky notes that are:
- made in the past hour
- newest ones first
- and a maximum of five of them
Here's how we would chain that query:
let anHourAgo = new Date(Date.now() - 60 * 60 * 1000);
return this.notes
.where('timestamp')
.above(anHourAgo)
.orderBy('timestamp')
.reverse()
.limit(5)
.toArray();
With all our methods made, we have our first Dexie database class written!
Dexie users should still learn about the built-in IndexedDB API's core concepts
As you can see from this tutorial, Dexie.js provides a beautiful abstraction over IndexedDB requests and transactions, taking a lot of event callback management out of the work you do with an IndexedDB database. I personally find Dexie to be a really satisfying API to use because of the simplicity it brings.
If this is your first experience with IndexedDB, though, it is still worth being familiar with the core concepts this technology. Ultimately, all of the functionality of Dexie is built on top of the built-in IndexedDB API, so that means that how IndexedDB works ultimately influences how Dexie works. Some of these concepts I find important to know about are:
- In IndexedDB, databases are composed of object stores, and you make indices on those object stores to make it more efficient to query for data by certain object fields. And as we saw, object stores and indices are a big part of Dexie as well.
- IndexedDB is a noSQL database, so while it has indices and the ability to make complex queries, since the database isn't relational like Postgres, you can't do joins between tables. So if you want to retrieve two kinds of data together, you'll want to design your object stores and indices around storing those kinds of data together.
- All IndexedDB interactions are asynchronous and work with the event loop to not block the JS runtime while running requests. This is why in the built-in API we get the results of requests with callbacks, while Dexie uses promises.
- You can take a closer look at your databases and the stored data in your browser by going do Developer Tools > Application > IndexedDB, and since Dexie is built on top of IndexedDB, you can still get that same convenient panel for debugging your apps!
Top comments (2)
Hi Andy, great tutorial! I did not know about Dexie, but it is interesting to see how it simplifies IndexedDB storage.
I took the liberty to try converting your Dexie's db.js to use AceBase, partly because I wanted to do some testing, and partly because I would love to let you and others know about its existence. I am the developer of the open source AceBase realtime database, which also has the option to store its data in the browser's IndexedDB. AceBase basically turns the browser's IndexedDB into a fullblown realtime NoSQL database with powerful querying options, with minimal coding. I created it over the past 3 years to replace my Firebase realtime databases with, because they were too limited for my requirements, and did not sufficiently support the browser/offline usage.
I created a gist as a drop-in replacement for the db.js of your Dexie tutorial here: gist.github.com/appy-one/257cc214d...
For more info about AceBase, see npmjs.com/package/acebase
Have a good weekend!
Cheers,
Ewout
Hi Andy - thank you for the tutorial! I'm sorry for the very basic question, but I'm looking at your index.html and seeing this :
But I don't see a file called main.js ... should that be page.js instead?