Introduction: In search of options

As web technologies have advanced, the ability to create offline web applications has moved closer and closer to a reality. I’ve written a number of small online web apps, but never something that acted like a desktop app that you accessed through the browser. So I decided to research offline storage options to see what the best fit would be to create a database layer underneath the actual app I wanted to use.

In my research, I found the following options

Local storage

Application Cache

Web SQL

IndexedDB

Of the four, I had to throw out local storage because it is merely a key-value storage mechanism and so wasn’t a good fit for an application that needs database functionality. Application Cache and Web SQL are deprecated APIs, and I didn’t want to run the risk of using them in an application just to have them removed.

So that left IndexedDB. Let’s dig into this API and see what we can do with it.

Starting with IndexedDB

IndexedDB is an asynchronous transactional database system designed specifically for storing large amounts of structured content. Which is great, since an offline app is going to need to store everything in the browser. And depending on the application, we might be storing large files (e.g., PDFs, images) in addition to text.

However, IndexedDB doesn’t store data in a table-based, columnar format like SQL, but in object stores, which house JavaScript objects indexed by a key. This means you don’t have to worry about creating a predefined schema as you would for MySQL, although you will still want to put some consideration into how you want to store and structure your data to make for efficient retrieval from the database.

So let’s create a small database to hold invoices. The classes of objects that we might want to store could be:

invoices (for metadata)

invoice-items (for line items on the invoice)

invoice-attachments (for scans of the invoice, bill of lading, etc.)

Now let’s create it!

Implementing IndexedDB

To get started with IndexedDB for the first time, we will want to run the following code:

const request = window.indexedDB.open("database", 1); // Create schema

request.onupgradeneeded = event => {

const db = event.target.result;



const invoiceStore = db.createObjectStore(

"invoices",

{ keyPath: "invoiceId" }

);

invoiceStore.createIndex("VendorIndex", "vendor"); const itemStore = db.createObjectStore(

"invoice-items",

{ keyPath: [ "invoiceId", "row" ] }

);

itemStore.createIndex("InvoiceIndex", "invoiceId"); const fileStore = db.createObjectStore(

"attachments",

{ autoIncrement: true }

);

fileStore.createIndex("InvoiceIndex", "invoiceId");

};

In the first line we start a request to open version 1 of a database named database . Since this is the first time the database is loaded, the browser will find no database with that name, so the request will fire the onupgradeneeded event. We want to create an event handler to handle our logic to create a new database.

We get the result from the fired event, which is an object that represents a connection to the database, and store that in a variable called db . Then we create three object stores and three indexes inside that database.

Each call to .createObjectStore() takes two parameters: a name, and an optional options object. We are creating stores called invoices , invoice-items , and attachments . However, we are passing in different options objects for each one, so let’s go over each.

In the first, we are simply saying that there is a property in each object we will store called invoiceId , and that the database should pair this property with the object itself (so that when we look up an object, we simply need to say “get by invoiceId ). However, key paths can be compound, so in the second we that invoiceId and row will serve as the key to retrieve an object. And finally, for the last store, by only passing autoincrement: true , we are saying there is no key path and the database should generate one for us.

Each object store can optionally have an index created for one or more properties, which allows for an additional way to retrieve objects than using the key path. Each .createIndex() call takes an index name, and what properties should be indexed.

One last thing to note: if a user revisits the site and performs the same connection request, the onupgradeneeded event will not fire unless the database version has been increased to a higher integer (so, 2 or higher in our case).

Implementing CRUD operations

The process flow for all CRUD operations is:

open a database connection

initiate a transaction

indicate which object store to use

perform the action on that store

clean up

Create

To create a new invoice in the database, we would do the following:

const request = window.indexedDB.open("database", 1);

request.onsuccess = () => {

const db = request.result;

const transaction = db.transaction(

[ "invoices", "invoice-items" ],

"readwrite"

);

const invStore = transaction.objectStore("invoices");

const itemStore = transaction.objectStore("invoice-items");



// Add data

invStore.add(

{ invoiceId: "123", vendor: "Whirlpool", paid: false }

);

itemStore.add({

invoiceId: "123",

row: "1",

item: "Dish washer",

cost: 1400

});

itemStore.add({

invoiceId: "123",

row: "2",

item: "Labor",

cost: 500

}); // Clean up: close connection

transaction.oncomplete = () => {

db.close();

};

};

If the request to open the database succeeds, then the onsuccess event handler will execute the transactions. We first assign the database to the variable db , then open a transaction on that database, specifying it is a readwrite operation. The first parameter to db.transaction() , is a list of the object stores we want the transaction to be able to see. In our case, since there are two types of data to represent an invoice, we are using the invoices and the invoice-items stores.

Then we access each object store and .add() the data. Once the transaction has successfully complete, we close our database connection.

Update

To update, we perform the same opening steps (connect to database, create a transaction, and access an object store). Let’s say we want to revise the cost for the dishwasher line item:

// ...open database, initiate transaction

const itemStore = objectStore.put({

invoiceId: "123",

row: "1",

item: "Dish washer",

cost: 1300

});

// ...clean up

This operation will determine the key path (as per our schema definition above), and update that object. The object itself is overridden with the object specified in .put() so make sure you include all the original fields and original data so that the document doesn’t lose any informatino.

For object stores that have explicitly defined key paths, it is not necessary to add the key as a second parameter. We only need to specify the key if we are having the database autogenerate one. So, if we wanted to update a scanned file in our attachments store, we would want to execute

attachmentStore.put({ new data}, KEY_NUMBER);

Also, one last thing about .put() : it runs double duty. If we did not have a line for invoice 123 and row 1 in our object store, this method would have created it for us (acting as .add() above).

Delete

If we wanted to delete the second line item on our invoice all we have to do is specify the desired key we want removed from the invoice-items store:

itemStore.delete([ "123", "2" ]);

Read

Reading data from the database is least like the other three operations in form in that in order the data to be usable, we have to implement another event handler. Let’s get the invoice we added to our database earlier:

// ...open database, initiate transaction

const getRequest = invStore.get("123");

getRequest.onsuccess = () => {

// Do something with the data

console.log(getRequest.result);

};

In order to get to the data returned from our object store, we have to create an onsuccess event handler. This event fires after the data has been pulled from the database and is ready for processing. It’s necessary to use an event handler because of the asynchronicity of the transaction; we don’t want later lines of code to assume that the data is ready when it doesn’t exist yet.

What are some ways we can handle this situation? We could create a variable in an outer scope of the onsuccess handler:

let data; const getRequest = invStore.get("123");

getRequest.onsuccess = () => {

data= getRequest.result;

};

We still have to worry about the fact that getData might not have the data assigned to it before it gets used later on down the function call. So while it works, it isn’t ideal.

The way I handle this is to create a wrapper function around the get request logic that executes a callback in the onsuccess handler.

const getFromDB(key, callbackFn) {

const request = window.indexedDB.open("database", 1);

request.onsuccess = () => {

const db = request.result;

const transaction = db.transaction("invoices", "readwrite");

const invStore = transaction.objectStore("invoices"); const get = invStore.get(key);

get.onsuccess = () => {

callbackFn(get.result);

};

};

};

This way you can encapsulate all business logic necessary to handling this data, and being sure you will handle it properly due to the asynchronous nature of IndexedDB transactions.

There is one further thing that needs to get said about reading data from an IndexedDB database: we can also do it using whatever indexes we created on an object store.

For example, if we wanted to get a list of all the invoices from a particular vendor, we can query the index:

// ...open database, initiate transaction

const invStore = transaction.objectStore("invoices");

const invIndex = invStore.index("VendorIndex");

const getRequest = invIndex.getAll("Whirlpool"); getRequest.onsuccess = () => {

// Do something with the data

console.log(getRequest.result);

};

We merely access the index declared in our object store, and run .getAll("Whirlpool") to pull all invoices from Whirlpool. We could also use .get("Whirlpool") , but this would only return one found match.

Conclusion

We had a brief introduction into initializing a new database, and performing basic CRUD operations on it. There’s much more to IndexedDB than this article can give (just going through MDN’s documentation shows there is so much more to do with it), and as I learn more, I hope to write additional articles on them. I’ll leave this gist as a complete example of basic IndexedDB usage.