/home/naiquevin

Async app context in Vue.js using promises

My guitar practice app, Captrice is built using Vue.js. As a backend developer (although with some Backbone.js experience from more than a decade ago), I chose Vue because it allowed me to build quickly without having to learn the ever-evolving libraries and tools in the frontend ecosystem today. I know my code may not follow current best practices and I'm fine with it as long as it's easy for me to reason about. I am the solo developer working on the codebase and my top priority is maintainability. But as the app is starting to grow in terms of features and complexity, it's time to revisit some parts that can be optimized for performance and extensibility.

The first thing I wanted to fix was redundant initialization of IndexedDB client in router views. It was a bit too inefficient and hacky for my comfort!

The problem

Captrice is a local-only1 app that stores user data in the browser's IndexedDB. In the early days there was only one top level component for the practice interface and that was the entire app. But soon the dashboard and library pages were added and routing was introduced. The question was - how to have multiple router views use the already initialized IndexedDB client? At that time I faced some roadblocks due to my limited understanding of Vue and to avoid being blocked on it for too long, I decided take the simplest approach i.e. initializing the IndexedDB client in every router view component that needed it. While it never caused any noticeable issues, it is indeed redundant execution of code. When a user navigates from practice page to dashboard page, the page doesn't reload in the browser, so the db client object is already available in memory. Why re-initialize it?

Had this been a backend application, I'd have moved the IndexedDB client to something that I refer to as the "app context."

App context in a frontend app

I don’t think "App context" is standard terminology. You may think of it as the common resources or "global state" that's initialized when an application starts, which can then be used during its life cycle. Usual examples are database connections (or pool), caches, HTTP clients etc. It's a very common pattern in backend apps, particularly web apps.

As a backend dev, I can draw some similarities between a typical backend app and a single page frontend application (SPA). Because SPAs handle routing/navigation by themselves (instead of page refresh), the term app context seems appropriate for the code that get initialized once when the page loads.

App context in a Vue app

App context is typically accessed through some form of dependency injection. Let's take the example of a backend web app. At the time of initializing the web server, an app context which is usually a kind of hash map or dictionary is also initialized and passed to it. When an incoming request is received, the framework invokes the handler function, injecting the app context as an argument. Web frameworks typically provide a way to associate handler functions with routes. The handler functions of a backend web app can be loosely mapped to the router views in Vue.

Dependency injection in Vue can be done either through props or the provide/inject API. In Captrice, props seem adequate as the context doesn't need to be injected in deeply nested children components.

Passing props from App.vue to the router views

One thing that tripped me when learning to use Vue was how passing props from the root component i.e. App.vue to the router views is not as straightforward as passing them from a regular Vue component to it's child components.

In case of a component, a prop can be simply passed as,

<!-- In App.vue -->
<MyComponent :colorScheme="colorScheme" />

In case of router view, additional syntax is required:

<!-- In App.vue -->
<RouterView v-slot="{ Component }">
  <component :is="Component" :colorScheme="colorScheme" />
</RouterView>

Here, a prop named colorScheme is passed from App.vue to all the router views that are rendered as it's child components. The value of colorScheme is obtained from localStorage at page load and becomes available in every router view that gets invoked as the user navigates to the different "pages". Pretty straightforward.

What if we try to do the same for an IndexedDB wrapper? An important difference is that the localStorage API is synchronous whereas IndexedDB operations are asynchronous.

Sure, we can make the created lifecycle hook async and await inside it. Using Vue's options API2, it looks something like this:

// In App.vue
export default {
  ...

  async created() {
    this.idb = markRaw(await initIndexedDb());
  },

  ...
}

And in App.vue's template,

<!-- In App.vue -->
<RouterView v-slot="{ Component }">
  <component :is="Component" :idb="idb" />
</RouterView>

Finally, in all the router view components:

// In router view component
export default {
  ...

  props: {
    idb: Object,
  },

  async created: {
    const data = await this.idb.fetchData();
  },

  ...
}

However, this doesn't quite work. While lifecycle hooks such as created are allowed to be defined as async, Vue doesn't wait for the async function calls in them to resolve. So this implementation will likely result in an error that says this.idb is null in router view's created hook, indicating that the IndexedDB client is not yet initialized to be able to call methods on it.

Here's an illustration of a failure scenario:

                           t0         t2                 t4
                            |  .       |      .           |
                            |  .       |      .           |
                            |  .       |      .           |
    created hook in App.vue |==.=======|======.===========|
                            |  .       |      .           |
                               .       |      |           |
                               |       .      |           .
  created hook in Component    |=======.======|===========.
                               |       .      |           .
                               |       .      |           .
                               |       .      |           .
                              t1             t3

t0: The created hook of App.vue is invoked.

t1: The created hook of App's child component i.e. the router view component is invoked. Despite the App's created hook being an async function, Vue doesn't wait for the async calls in it to resolve before proceeding with it's child components' lifecycle hooks.

t2: await initIndexedDb()is called in App's created hook (but the promise resolves only at t4.

t3: this.idb.fetchData() is called in child component's created hook but the promise returned by initIndexedDb in App's created hook is not resolved yet. This means the value of the idb prop is still null, resulting in an error.

t4: The promise returned by initIndexedDb resolves in App's created hook, and only after this point, the idb prop in the child component is usable.

It's easy to confirm the above by adding console.log statements.

Passing promise as a prop

The above can be worked around by passing a prop that's a promise instead of a value.

// App.vue
export default {
  ...

  async created() {
    this.ctxPromise = new Promise(async (resolve) => {
      const idb = await initIndexedDb();
      resolve({
         idb: idb,
      });
    });
  },

  ...
}

In the router view's created hook, we await on the promise before interacting with the database.

// Router view component
export default {
  ...

  props: {
    appCtxPromise: Promise,
  },

  async created: {
    const appCtx = await this.appCtxPromise;
    this.idb = markRaw(appCtx.idb);
    const data = await this.idb.fetchData();
  },

  ...
}

Now it's guaranteed that before any db calls are made, the app context and hence, the db object are initialized. An important thing to note is that App's created hook should not await for some other promise before the ctxPromise is created, otherwise the appCtxPromise prop in router view's created hook could be null when trying to wait for it to resolve.

This approach fixes the redundant execution of code, allows code reuse and provides a framework for future additions to the app context, all without using any external dependencies.


Footnotes

1. The goal is to be a local-first app, that will sync user data with a backend, mainly for cross-device usability and backups. However, in the current version users' data is only stored locally in their browsers.

2. The composition API seems to be more widely recommended, but I find the options API easy to reason about and work with.

Please reach out to me at anvxiva@tznvy.pbz if you want to discuss this article. I've disabled the comments section after Disqus started embedding ads. I hardly get any comments any way so it doesn't seem worth paying for a premium plan.