DEV Community

Andy Haskell
Andy Haskell

Posted on

Using Dexie.js to write slick IndexedDB code

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:

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:

Sample screenshot of the web app from the tutorial, with a text box for adding messages to a list, and a list for displaying the messages in the database

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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');
} 
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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 IndexedDB createObjectStore 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');
Enter fullscreen mode Exit fullscreen mode

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:

  1. Starting a readwrite transaction on the notes object store so no other interactions with that store can happen at the same time, and then retrieving our object store with IDBTransaction.objectStore.
  2. Calling IDBObjectStore.add to get an IndexedDB request to add the sticky note.
  3. 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() });
}
Enter fullscreen mode Exit fullscreen mode

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 = '';
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Start a readonly transaction on our notes object store.
  2. Retrieve the object store with IDBTransaction.getObjectStore.
  3. Open a cursor for our query we want to make.
  4. 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();
}
Enter fullscreen mode Exit fullscreen mode

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's then 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();
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
appyone profile image
Ewout Stortenbeker • Edited

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

Collapse
 
jcghalo profile image
jcghalo

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?