Wrapping up Firebase JS Promises with Coroutines & Flow

Kotlin-JS
Firebase Realtime DB

A Promise is the way you traditionally deal with asynchronous requests in the Javascript world.

With Kotlin-JS, here is how you would typically load an entry, using only the Firebase Javascript APIs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun example(database:Database) {
    database.ref("customer")
        .child(childKey)
        .get()
        .then { snap ->
            val customer = snap.`val`().unsafeCast<Customer>()
            logI("value: %o", customer)
        }
        .catch { throwable ->
            logE("Caught error: %o", throwable)
        }
}

This works fineā„¢. The problem with Promises is that they are specific to Javascript. If we hope to graduate to a Kotlin Multi-Platform setup one day, we can’t rely too much on them.

Thankfully, we can use Kotlin coroutines and the Flow API to make Firebase’s callback-based APIs easier to use.

The problem with Promises

Here is an example. When initializing a hosted Firebase web project, your Firebase configuration object is automatically generated and made available under "/__/firebase/init.json".

Kotlin-JS exposes the Web Fetch API. It returns a Promise to a Response. Once the fetch call resolves, we then call .json() on that Reponse, which in turn gives us another Promise. When that Promise resolves, we get a Json object that holds our config data.

Not bad, but...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
window.fetch("/__/firebase/init.json")
    .then { response ->
        response.json() // Convert to Json
            .then { firebaseConfig ->
                firebaseInitApp(firebaseConfig)
                // TODO: App initialization goes here!
            }
            .catch { throwable ->
                console.error(throwable)
            }
    }
    .catch { throwable ->
        console.error(throwable)
    }

As you can see, the Ryus in the margin hint at the issue with this code, i.e. double nested async calls. Having nested asynchronous callbacks makes error handling a pain. It also makes it hard to follow the initialization process.

And the other bad news is, that problem will compound over time. We haven’t even gotten to initializing our authentication setup yet! What can we do here?

The Javascript solution

A popular ‘javascript native’ solution would be to use the async npm package. It allows you to run multiple asynchronous operations sequentially.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
async.waterfall([
  function(callback) {
    callback(null, 'one', 'two');
  },
  function(arg1, arg2, callback) {
    // arg1 now equals 'one' and arg2 now equals 'two'
    callback(null, 'three');
  },
  function(arg1, callback) {
    // arg1 now equals 'three'
    callback(null, 'done');
  }
], function (err, result) {
  // result now equals 'done'
}
);

Idiomatic JS

I left out the specifics for our fetch call, but it’s easy enough to see how we can use this Node package to solve our problem.

Using Kotlin’s suspendCoroutine { }

In Kotlin, we have the Coroutines library available to us. We can use the suspendCoroutine {} function to wrap our window.fetch() call:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Allows the following:
fun main() {
	GlobalScope.launch {
	    val firebase: dynamic = require("firebase/app").default
	    require("firebase/database")
	
	    val fbConfig = suspendFetch("fbConfig.json")
	    firebase.initializeApp(fbConfig)
	    val fbDb = firebase.database().unsafeCast<Database>()
	    
	    // ... App init can continue sequentially here.
	}
}

// A small, reusable suspending fetch function.
suspend fun suspendFetch(input:String): Json? {
    return suspendCoroutine { continuation ->
        window.fetch(input).then { res ->
            res.json().then { config ->
                continuation.resume(config.unsafeCast<Json>())
            }
        }.catch { throwable ->
            console.log(throwable)
            continuation.resume(null)
        }
    }
}

Idiomatic Kotlin

As you can see above, by using Kotlin Coroutines, we have a nice idiomatic way to get rid of callback hell. And, we can use this approach to wrap up Firebase DB calls too.

Suspending Firebase DB calls

In my last Kotlin-JS post, I showed you how to call on Firebase’s real-time DB APIs. Something like this:

1
2
3
4
5
6
7
8
database.ref("customers/$key")
    .get() { snap, _ ->
        val customer = snap.`val`().unsafeCast<Customer>()
        console.log("Customer: %o", customer)
    }
    .catch { throwable ->
        console.error("Error on get(): %o", throwable)
    }

Again we’re stuck with a callback type API. Converting that to a suspending function gives us:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
GlobalScope.launch {
	val customer = suspendOnceCustomer(firebaseDb, customerKey)
}

suspend fun suspendOnceCustomer(database:Database, key:String):Customer {
    return suspendCoroutine { continuation ->
        database.ref("customers/$key")
            .get() { snap, _ ->
                val value = snap.`val`()
                continuation.resume(value.unsafeCast<Customer>())
            }
            .catch { throwable ->
                console.error("Error on get(): %o", throwable)
                continuation.resumeWithException(throwable)
            }
    }
}

But, while we’re at it, why not make a generic extension function out of this?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
GlobalScope.launch {
	val customer:Customer = firebaseDb.suspendGet(customerRef)
}

suspend fun <T> Database.suspendGet(refPath:String):T {
    return suspendCoroutine { continuation ->
        ref(refPath).get()
            .then { snap ->
                val castValue = snap.`val`().unsafeCast<T>()
                continuation.resume(castValue)
            }
            .catch { throwable ->
                logE("Error on get(): %o", throwable)
                continuation.resumeWithException(throwable)
            }
    }
}

This covers most use cases of the Firebase get() function, and also leverages Kotlin’s type-inference (see line 2) to keep things tidy.

Tying up loose ends with Flow

There’s one last use case I want to cover today. Firebase allows you to register handlers that trigger every time the data at the source is updated.

1
2
3
4
5
6
database.ref("customers/$key")
	 // The handler below fires every time the customer value changes.
    .on("value", { snap, _ -> 
        val customer = snap.`val`().unsafeCast<Customer>()
        console.log("Customer: %o", customer)
    })

We can use the Kotlin Flow APIs to give us a better… control flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
val scope = MainScope()
scope.launch {
	database.ref("customer/$key")
		.asFlow()
		.collect { customer ->
			console.log("Customer: %o", customer")
		}
}

fun <T> Query.asFlow(): Flow<T> {
   return callbackFlow {
      val onValueChange = on("value", { snap, _ ->
         val value = snap.`val`().unsafeCast<T>()
         offer(value)
      })

      awaitClose {
         off("value", onValueChange)
      }
   }
}

Using Flow here allows you to collect the data from a Coroutine scope when one of your components initializes. When your component lifecycle terminates, calling scope.cancel() will automatically cancel all active collect {} calls associated with a given components scope.

That’s it for now!

In this article, we’ve seen how Coroutines can improve control flow in an application. Coroutines make our code easier to understand, letting us avoid ‘javascript callback hell’.

We’ve also covered how to convert Firebase callback-based APIs into a stream of information with Kotlin’s Flow APIs.

There’s still a lot to explore with Kotlin-JS. For example, how to work with parallel asynchronous calls. That’s worth an article on its own. Watch this space for more soon!

You can find the backing code samples for this post in my Kotlin-JS Github project folder.

I’m also quite interested in getting feedback. Anything else you’d like to see me cover next? Questions, deep-dive requests, ideas for improvements? DM me on Twitter, and we’ll chat.

Thanks for your time, and stay safe!