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 small 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.

Stage 1: Chaining Promises

NOTE: In the first version of this article, I ended up focusing on using Coroutines to avoid ‘pyramid of doom’ callback situations. I’ve since realized this was something entirely avoidable with Promises. (I’m still somewhat of a JS newb after all.) As a result, I’ve edited this section. Many thanks to Tiger Oakes for his proofreading help.

Let’s start with a simple Promise chaining 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 Response, which gives us another Promise. When that Promise resolves, we get a Json object that holds our config data.

Let’s see what this chaining looks like:

1
2
3
4
5
6
7
window.fetch("/__/firebase/init.json")
	.then { res: Response -> res.json() }
	.then { config: Any? -> config.unsafeCast<Json>() }
	.then { jsonConfig: Json -> firebaseInitApp(jsonConfig) }
	.catch { throwable -> console.log(throwable) }
	
	// Only issue is, when will the Firebase API be ready to do some work? 🤷‍♀️ 

This is a tidy bit of code, but of course, we don’t really know when the callback will take place. It’s all within expectations, of course. The fetch call is async, and the Promise will eventually resolve and call back our firebaseInitApp() function.

This becomes an issue for code that depends on firebaseInitApp() having completed before it can run. Which, in our case, would be most of our web app. We end up having to weave our control flow into the Promise callback:

1
2
3
4
5
	// Handling app startup asynchronously.
	.then { jsonConfig: Json -> 
		firebaseInitApp(jsonConfig) 
		startApp()
	}

Let’s investigate how using a more sequential flow could make it easier to reason about our program.

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 now have an idiomatic way to initialize and start our application sequentially. By keeping our control flow sequential like this, it becomes easier to reason about our code.

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!