I came across some situations where the composition API shows its flexibility, these are some patterns of Vue.ref that do a lot of work for you.

Custom Refs

With the composition API you can use the reactivity of Vue and apply it to units of data without associating them with components.

I recommend you familiarize yourself with Vue.ref and the composition API before reading this article.

In this article I'm NOT talking about Vue.customRef. The customRef function is flexible but I have yet to explore its possibilities.

I will be talking about baking in extra behavior on top of Vue.ref or in better words "composing logic on top of it"

The idea is simple, we want to introduce some sort of persistence for some of our refs.

This comes in handy for building offline-first applications. In these applications you first display the stale data then you update them in the background.

Another area where this is useful is with expensive data computations that may slow our application response as we cannot afford to calculate their values each time the webpage is requested.

For these scenarios, I have two interesting solutions.

Client-Side Stored Refs

It is beneficial for PWAs and offline-first applications to load stale data first. That way the user always has content to work with until the new data is fetched.

We can choose whatever storage API to store the data. Whatever the choice is, we don't want to explicitly call the storage API, as we want our custom ref to handle that for us. In other words we want to have a self-managed cache system.

Let's start with the simplest, being localStorage. Here is a simple snippet:

refs.jsjsimport { ref, computed } from 'vue';

function useStoredRef(def, key) {
  const innerRef = ref(def); // initialize a ref with the default value
  const cachedValue = localStorage.getItem(key);
  if (cachedValue) {
    // if there is a cached value, deserialize it and assign it to the ref
    innerRef.value = JSON.parse(cachedValue);
  }

  watch(
    innerRef,
    (newVal) => {
      // Serialize the new value and store it in local storage
      localStorage.setItem(key, JSON.stringify(newVal));
    },
    {
      deep: true,
    }
  );

  return innerRef;
}

With this isolated ecosystem, whenever the value changes, it is serialized to JSON and stored to the local storage. Whenever a ref is created with the same key it will pick up the old value and sync it as well.

You can do the same with indexedDB but with a little bit of magic, you can use wrappers like idb-keyval to make it behave like key-val store:

refs.jsjsimport { ref, toRaw, watch } from 'vue';
import { get, set } from 'idb-keyval';

export function useStoredRef(value, key) {
  const wrapper = ref(value);

  get(key)
    .then((cachedValue) => {
      // since the api is async, we want to make sure that
      // we update the ref value if it wasn't changed already
      // by other logic in our code
      if (toRaw(wrapper.value) === value && cachedValue !== undefined) {
        wrapper.value = JSON.parse(cachedValue);
      }
    })
    .catch((err) => {
      console.error(err);
    });

  watch(
    wrapper,
    async (newValue) => {
      // Sync the new value to the idb storage
      // While we can set objects in idb, Vue adds some stuff to our objects that cannot be serialized into idb
      // so a JSON.stringify conversion is needed. `toRaw` doesn't work well in all cases either.
      await set(key, JSON.stringify(newValue));
    },
    {
      deep: true,
    }
  );

  return wrapper;
}

Using the IndexdDB as storage complicates things because the API is asynchronous. But it has other things going for it like storage quota and it shouldn't block the DOM if you have large objects. So depending on your needs, either would do the job fine.

For either version, you can use them like this:

jsimport { useStoredRef } from './refs';

// Get an articles ref with a default value of empty array
const quotes = useStoredRef([], 'articles');

articles.value.push({
  id: 1,
  title: 'Type-safe Vue.js Injections',
});

articles.value.push({
  id: 2,
  title: 'Language Aware Nuxt.js Routing',
});

Now if you reload your page, you will notice that the articles we added are still there. You can check this in action here:

In either versions you may need to handle cases where the data quota is too low or if storage options are disabled.

Server Cached Refs

In a similar fashion to the previous example, we need to cache a computationally-expensive state on the server-side. To give you a real-world example, we needed to load the categories for e-commerce using Nuxt.js.

We need to display the categories immediately for SEO and layout purposes. Because the categories are always going to be the same for all users, it doesn't make sense to re-fetch them each time from the API.

It would be great if we could fetch every once and a while for all users requesting the webpage and not per user. This means we need to use the server's memory to cache the categories for an arbitrary TTL value, let's say 5 minutes for example.

This could have a big impact on your server's response-time, we will apply this to a Nuxt server.

Currently, Nuxt.js 3 isn't out yet so we will be using the @nuxtjs/composition-api package which does an excellent job enabling you to use the composition API with Nuxt 2.x today.

We can use the lru-cache npm package to store the cached items. Remember that this is a simple in-memory storage and while is fast, it doesn't scale well.

Before I implement our server-side ref, first I need to create a Nuxt.js module that injects the LRU cache into the ssrContext object, which will make it available to me later on.

modules/ssrCache.jsjsimport LRU from 'lru-cache';
import { defineNuxtModule } from '@nuxtjs/composition-api';

/**
 * A cache module for the Server to cache various stuff
 */
const cacheModule = defineNuxtModule(function StoreServerCacheModule() {
  const FIVE_MINUTES = 1000 * 60 * 5;
  const storeCache = new LRU({ maxAge: FIVE_MINUTES });

  // Inject the SSR cache instance
  this.nuxt.hook('vue-renderer:ssr:prepareContext', (ssrContext) => {
    ssrContext.$serverCache = storeCache;
  });
});

export default cacheModule;

And then in the nuxt.config.js I need to register that custom module:

js{
  modules: [
    "@/modules/ssrCache"
  ],
}

Now that I've the LRU cache instance injected into the ssrContext, I can implement the new ref, let's call it useCachedSsrRef.

composition/refs.jsjsimport { computed, ssrRef, useContext } from '@nuxtjs/composition-api';

// Default TTL of 5 minutes
const CACHE_TTL = 3600 * 1000 * 5;

/**
 * Creates a cache-able SSR ref, the caching only occurs on server-side, client side is unaffected.
 * Helps speed up expensive operations of resources that can be stale for some time like categories and store config.
 */
export function useCachedSsrRef(def, key, ttl = CACHE_TTL) {
  const innerRef = ssrRef(def, key);

  // Not even running on server
  // Just give them a regular ref with the default value
  if (!process.server) {
    return innerRef;
  }

  const ctx = useContext();
  const ssrContext = ctx.ssrContext;

  const serverCache = ssrContext.$serverCache;
  if (!serverCache) {
    return innerRef;
  }

  // initialize ref with the cached value
  if (serverCache.has(key)) {
    innerRef.value = serverCache.get(key);
  }

  // wrap the SSR ref with computed prop that updates the cache value
  // we didn't use watch because we want to catch updates to values to values synchronously
  // watch() is async, so it wouldn't write in the cache properly
  const wrapper = computed({
    get: () => innerRef.value,
    set(newVal) {
      // write in cache
      if (serverCache) {
        serverCache.set(key, newVal, ttl);
      }

      innerRef.value = newVal;
    },
  });

  return wrapper;
}

And that's it, notice that we used computed instead as a gatekeeper to our ref. This has some limitations but watch doesn't work well because it doesn't execute the callback immediately after the value change, which means computed works better here. I think once Nuxt 3 comes out this would work as expected with watch's flush modes.

Now with that function implemented you can test it out:

jsimport { useCachedSsrRef } from '~/refs';

const articles = useCachedSsrRef([], 'articles', 3600 * 1000 * 5);

// invokes the setter, don't use `push`
articles.value = [{...}, {...}, {...}];

Here it is in action, notice the timestamp doesn't change until after 1 minute. Meaning we are only fetching the articles once every minute. Feel free to tweak that to whatever suits you.

I'm confident you can do the same with the vanilla Vue 3 SSR, but I haven't got the chance to try it out yet.

Conclusion

We've seen how to combine Vue.computed and Vue.watch to combine frequently needed behaviors into a neat re-usable API.

Introducing self-managed-caching on server-side and client-side has a lot of benefits and can help you out when you need atomic control over your data.

I will be sharing more findings as I work with the composition API.

Thanks for reading 👋