Skip to content

Best Practices

David Fahlander edited this page Aug 23, 2016 · 128 revisions

1. Understand Promises

Make sure you learn at least the basic practices of A+ promises before diving too deep into Dexie.

Here's a little test. Please review the code below and then ask yourself if you understood what it was doing...

function doSomething() {
    // Important: Understand why we use 'return' here and what we actually return!
    return db.friends.where('name').startsWith('A').first().then(function (aFriend) {
        return aFriend.id; // Important: Understand what 'return' means here!
    }).then (function (aFriendsId) {
        // Important: Understand what it means to return another Promise here:
        return fetch ('https://blablabla/friends/' + aFriendsId);
    });
}

// Important: Undestand how you would call and consume the doSomething() function.

Was there any of the "Understand" parts that you didn't understand? Then you would benefit from learning a little about promises. This will be useful knowledge whatever lib you'll be using. Some links to dive into:

2. Be wise when catching promises!

Short Version:

Top-level code (such as on event handlers) is where you should catch promises.
Everywhere else, you should return promises to your callers rather than
catching them, unless:
   ...you really handle the error (not just log it)
   ...you just want to log it, but then also re-throw it. 

Long Version:

It's bad practice to this everywhere:

function somePromiseReturningFunc() {
    return db.friends.add({
        name: 'foo',
        age: 40
    }).catch (function (err) {
        console.log(err);
    });
}

It's much better to do just this:

function somePromiseReturningFunc() {
    return db.friends.add({
        name: 'foo',
        age: 40
    });
    // Don't catch! The caller wouldn't find out that an error occurred if you do!
    // We are returning a Promise, aren't we? Let caller catch it instead!
}

If you catch a promise, your resulting promise will be considered successful. It's like doing try..catch in a function where it should be done from the caller, or caller's caller instead. Your flow would continue even after the error has occured.

In transaction scopes, it is even more important to NOT catch promises because if you do, transaction will commit! Catching a promise should mean you have a way to handle the error gracefully. If you don't have that, don't catch it!

function myDataOperations() {
    return db.transaction('rw', db.friends, db.pets, function(){
        return db.friends.add({name: 'foo'}).then(function(id){
            return db.pets.add({name: 'bar', daddy: id});
        }).then (function() {
            return db.pets.where('name').startsWith('b').toArray();
        }).then (function (pets) {
            ....
        }); // Don't catch! Transaction SHOULD abort if error occur, shouldn't it?

    }); // Don't catch! Let the caller catch us instead! I mean we are returning a promise, aren't we?!
}

But on an event handler or other root-level scope, always catch! Why?! Because you are the last one to catch it since you are not returning Promise:

somePromiseReturningFunc().catch(function (err) {
    $('#appErrorLabel').text(err);
    console.error(err.stack || err);
});

Sometimes you really WANT to handle an explicit error because you know it can happen and you have a way to work around it.

function getHillary() {
    return db.friends.where('[firstName+lastName]').equals(['Hillary', 'Clinton']).toArray()
          .catch('DataError', function (err) {
              // May fail in IE/Edge because it lacks support for compound keys.
              // Use a fallback method:
              return db.friends.where('firstName').equals('Hillary')
                       .and(function (friend) { return friend.lastName == 'Clinton'; });
          });
    });
});

In the above exampe, we are handling the error because we know it may happen and we have a way to solve that.

What about if you want to log stuff for debugging purpose? Just remember to rethrow the error if you do.

function myFunc() {
    return Dexie.Promise.resolve().then(function(){
        return db.friends.add({name: 'foo'});
    }).catch(function (err) {
        console.error("Failed to add foo!: " + err);
        throw err; // Re-throw the error!
    }).then(function(id){
        return db.pets.add({name: 'bar', daddy: id});
    }).catch(function (err) {
        console.error("Failed to add bar!: " + err);
        throw err; // Re-throw the error!
    }).then (function() {
        ...
    });
});

Since Dexie v1.3.6, all uncaught Dexie.Promises will by default be logged to the console (using console.warn()). You can override this behavior by subscribing to Dexie.Promise.on('error'):

Dexie.Promise.on('error', function(err) {
    // Log to console or show en error indicator somewhere in your GUI...
    console.error('Uncaught Promise: ' + (err.stack || err));
    $('#appErrorLabel').text(err.message || err);
    return false; // Don't call the default handler.
});

Subscribing to this event overrides the default handler (that logs to console) if you return false from your handler. Note that when this event is triggered, your code has failed to catch a promise at the top of its chain, for example in an event handler that does not return a promise itself.

3. Don't use other async APIs inside transactions!

IndexedDB will commit a transaction as soon as it isn't used within a tick. This means that you MUST NOT call any other async API (at least not wait for it to finish) within a transaction scope. If you do, you will get a TransactionInactiveError thrown at you as soon as you try to use the transaction after having waited for the other async API. This is a behaviour of IndexedDB that is hard to do anything about.

4. Only use Dexie.Promise inside transactions!

Dexie.Promise is ES6 and A+ compliant, meaning that you can use any favourite promise together with Dexie. However, within transactions, DO NEVER use any other promise implementation than Dexie.Promise! Otherwise the effective transaction will be gone.

For example, if you want to use Promise.all() or Promise.resolve(), make sure to use those methods from Dexie.Promise instead:

var Promise = Dexie.Promise; // Have this line where you do transactions

db.transaction('rw', db.friends, db.pets, function () {
    return Promise.resolve().then(function () {
        return Promise.all([
            db.friends.add({name: 'foo'}),
            db.friends.add({name: 'bar'})
        ]);
    }).then (friendIds) {
        return Promise.all(friendIds.map(function (id) {
            // Give a cat to each added friend:
            return db.pets.add({kind: "Cat", friendId: id});
        });
    });
});

5. Use transaction() scopes wherever you gonna make more than one operation

Whenever you are going to do more than a single operation on your database in a sequence, use a transaction. This will not only encapsulate your changes into an atomic operation, but also optimize your code! Internally, non-transactional operations also use a transaction but it is only used in the single operation, so if you surround your code withing a transaction, you will perform less costly operations in total.

Using transactions gives you the following benefits:

  • Robustness: If any error occur, transaction will be rolled back!
  • Simpler code: You may do all operations sequencially - they get queued on the transaction.
  • One single line to catch them all - exceptions, errors wherever they occur.
  • You can just fire off database operations without handling returned promises. The transaction block will catch any error explicitely.
  • Faster execution
  • Remember that a browser can close down at any moment. Think about what would happen if the user closes the browser somewhere between your operations. Would that lead to an invalid state? If so, use a transaction - that will make all operations abort if browser is closed between operations.

Here is how you enter a transaction block:

db.transaction("rw", db.friends, db.pets, function() {
    db.friends.add({name: "Måns", isCloseFriend: 1}); // unhandled promise = ok!
    db.friends.add({name: "Nils", isCloseFriend: 1}); // unhandled promise = ok!
    db.friends.add({name: "Jon", isCloseFriend: 1});  // unhandled promise = ok!
    db.pets.add({name: "Josephina", kind: "dog"});    // unhandled promise = ok!
    // If any of the promises above fails, transaction will abort and it's promise
    // reject.

    // Since we are in a transaction, we can query the table right away and
    // still get the results of the write operations above.
    var promise = db.friends.where("isCloseFriend").equals(1).toArray();

    // Make the transaction resolve with the last promise result
    return promise;

}).then(function (closeFriends) {

    // Transaction complete.
    console.log("My close friends: " + JSON.stringify(closeFriends));

}).catch(function (error) {

    // Log or display the error.
    console.error(error);
    // Notice that when using a transaction, it's enough to catch
    // the transaction Promise instead of each db operation promise.
});

Notes:

  • friends and pets are objectStores registered using Version.stores() method.
  • "rw" should be replaced with "r" if you are just going to read from database.
  • Also errors occurring in nested callbacks in the block will be catched by the catch() method.

6. Rethrow errors if transaction should be aborted

Saying this again.

When you catch database operations explicitely for logging purpose, transaction will not abort unless you rethrow the error or return the rejected Promise.

db.transaction("rw", db.friends, function() {
    db.friends.add ({name: "Måns", isCloseFriend: 1})
      .catch(function (error) {
          console.error("Couldnt add Måns to the database");
          // If not rethrowing here, error will be regarded as "handled"
          // and transaction would not abort.
          throw error;
      });
    db.friends.add ({name: "Nils", isCloseFriend: 1});
});

If not rethrowing the error, Nils would be successfully added and transaction would commit since the error is regarded as handled when you catch the database operation.

An alternate way of rethrowing the error is to replace throw error; with return Promise.reject(error).

7. (Optionally:) Declare Classes

When you declare your object stores (db.version(1).stores({...})), you only specify nescessary indexes, not all properties. A good practice is to have a more detailed class declaration for your persistant classes. It will help the IDE with autocomplete making life easier for you while coding. Also, it is very good to have a reference somewhere of what properties are actually used on your objects.

There are two different methods available to accomplish this. Use whichever you prefer:

  1. mapToClass() - map an existing class to an objectStore
  2. defineClass() - let Dexie declare a class for you

Whichever method you use, your database will return real instances of your mapped class, so that the expression

(obj instanceof Class)

will return true, and you may use any methods declared via

Class.prototype.method = function(){}

.

Method 1: Use mapToClass() (map existing class)
var db = new Dexie("MyAppDB");

db.version(1).stores({
    folders: "++id,&path",
    files: "++id,filename,extension,folderId"
});

// Folder class
function Folder(path, description) {
    this.path = path;
    this.description = description; 
}
Folder.prototype.save = function () {
    return db.folders.put(this);
}

/// File class
function File(filename, extention, parentFolderId) {
    this.filename = filename;
    this.extention = extention;
    this.folderId = parentFolderId;
}

File.prototype.save = function () {
    return db.files.put(this);
}

db.folders.mapToClass(Folder);
db.files.mapToClass(File);
Method 2: Use defineClass()
var db = new Dexie("MyAppDB");

db.version(1).stores({
    folders: "++id,&path",
    files: "++id,filename,extension,folderId"
});

var Folder = db.folders.defineClass({
    id: Number,
    path: String,
    description: String
});

Folder.prototype.save = function () {
    return db.folders.put(this);
}

var File = db.files.defineClass({
    id: Number,
    filename: String,
    extension: String,
    folderId: Number,
    tags: [String]
});

File.prototype.save = function () {
    return db.files.put(this);
}
Clone this wiki locally