Writing a Cache Composable in Nuxt 3

Today we’ll be writing our own composable that adds basic caching to useFetch.

Michael Thiessen
Nuxt 3

Mastering Nuxt 3 course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view the Nuxt 3 course

Nuxt makes data fetching really simple with useFetch and useAsyncData — but they don’t come with caching.

That’s why we’ll be writing our own composable that adds basic caching to useFetch in this tutorial.

Along the way we’ll learn about:

  • Working with session storage
  • useFetch
  • Creating reusable code with TypeScript generics

Here’s the final code for our useFetchWithCache composable:

// ~/composables/useFetchWithCache.ts

import { StorageSerializers } from '@vueuse/core';

export default async <T>(url: string) => {
  // Use sessionStorage to cache data
  const cached = useSessionStorage<T>(url, null, {
    serializer: StorageSerializers.object,
  });

  if (!cached.value) {
    const { data, error } = await useFetch<T>(url);

    if (error.value) {
      throw createError({
        ...error.value,
        statusMessage: `Could not fetch data from ${url}`,
      });
    }

    // Update the cache
    cached.value = data.value as T;
  } else {
    console.log(`Getting value from cache for ${url}`);
  }

  return cached;
};

Here are two examples for how this might be used:

// Get all users
const user = useFetchWithCache<User[]>('/api/users') 

// Or fetch the settings
const settings = useFetchWithCache<Settings>('/api/settings')

We don’t get caching by default

Nuxt 3 comes with some amazing utilities for data fetching, like useAsyncData and useFetch.

These composables handle SSR complexities for us, like making sure our state is hydrated correctly and isn’t leaked to other users by accident.

We also get great type support. Routes are fully typed so we get autocomplete, and we can check that we’re using the right endpoints. It will also infer the type of the data returned by each endpoint!

But useAsyncData — and useFetch which is a wrapper for useAsyncData — do not come with caching built in.

(At first, caching was included with useAsyncData, but this caused too many issues and was removed for the stable release of 3.0)

Luckily for us, implementing our own caching isn’t too difficult. So let’s get to it!

Writing the useFetchWithCache composable

Here’s our game plan.

First we’ll build a composable that interacts with the cache. Cache invalidation is hard, so as an easy solution we’ll just use sessionStorage. You can choose to build whatever caching strategy you want into this.

Next, we’ll integrate useFetch so we’re actually getting data from an endpoint.

Then we’ll make sure it’s type safe. We’ll be using TypeScript generics so that we continue to get type safety from our APIs.

Last — but certainly not least! — we’ll also add on some basic error handling.

Caching with Session Storage

First, we start with a basic composable that interacts with the cache:

// ~/composables/useFetchWithCache.ts

import { StorageSerializers } from '@vueuse/core';

export default async (key) => {
  const cached = useSessionStorage(key, null, {
    serializer: StorageSerializers.object,
  });

  return cached;
};

We pass in a key, and we get back a ref that is synced to our session storage:

// Get our synced ref
const cachedSettings = useCache('settings');

// Update the value of the ref (which is then saved to session storage)
cachedSettings.value = { darkMode: true }; 

// The tab is closed and reopened (we're still in the same session)
console.log(cachedSettings.value.darkMode); // true

We’re not actually doing anything that interesting yet, we’re just wrapping VueUse’s useSessionStorage composable.

However, we are passing in a custom serializer. Since session storage only stores strings, we have to serialize and deserialize any Javascript objects that we want to store. Normally useSessionStorage will automatically detect which one to use, which is great!

But since we’re passing in a default value of null, it has no clue what to do, so we need to tell it explicitly.

We do this to simplify checking if the cache has been initialized or not. If we set the default value to an empty object it would be able to infer the correct serializer to use. Then, our check would have to change from !cached.value to something like Object.keys(cached.value).length === 0 to check for an empty object.

Fetching Data

Now we’ll fetch some actual data with useFetch:

// ~/composables/useFetchWithCache.ts

import { StorageSerializers } from '@vueuse/core';

export default async (url) => {
  const cached = useSessionStorage(url, null, {
    serializer: StorageSerializers.object,
  });

  if (!cached.value) {
    const { data} = await useFetch(url);
    cached.value = data.value;
  } else {
    console.log(`Getting value from cache for ${url}`);
  }

  return cached;
};

If we look in the cache for our value and it’s not there, we have a cache miss. In that case, we need to fetch the data from the provided url.

Once we get that value back, we need to update the cache, so any future calls can reuse the value immediately. This is pretty easy to with the refs we have set up:

const { data} = await useFetch(url);
cached.value = data.value;

That’s the main functionality down. Now it’s time to polish things up!

Making it Typesafe with Generics

I’m a huge fan of TypeScript, so we also want to make this composable type safe.

For that, we’ll have to use generics.

You can think of generics as arguments for types. Just like we pass arguments into functions to allow them to be more flexible, we can use generics as “type arguments” to make our types more flexible.

For example, useFetch allows for generics, so instead of always using a specific type within the method, it can use whatever type we need it to:

// Fetching a list of people
const people = useFetch<Person>('/api/person')

Here we’re passing the Person type into the useFetch method. This lets it know that the data it’s returning is of the type Person.

This is how we use generics in our useFetchWithCache composable:

// ~/composables/useFetchWithCache.ts

import { StorageSerializers } from '@vueuse/core';

                     👇
export default async <T>(url: string) => {
  // Use sessionStorage to cache data
                                  👇
  const cached = useSessionStorage<T>(url, null, {
    serializer: StorageSerializers.object,
  });

  if (!cached.value) {
                                   👇
    const { data } = await useFetch<T>(url);

    // Update the cache         👇
    cached.value = data.value as T;
  } else {
    console.log(`Getting value from cache for ${url}`);
  }

  return cached;
};

Typically the type T is used to represent the generic type. We’re using it in a few different places.

First, we define that this function is using the generic T by adding the angle brackets <T> to the function signature.

Then, we pass the type into useSessionStorage, since the object we’re storing in the cache is of type T. TypeScript can now infer that cached has the type Ref<T>.

We do a similar thing with useFetch.

The last time we use the generic type is when we update the cache:

cached.value = data.value as T;

TypeScript should know already that data.value has the type T, but for some reason it doesn’t. So I’m casting it to type T and letting TypeScript know that it definitely should be that type.

This is what it looks like if we fetch a list of people:

// Fetching a list of people
const people = useFetchWithCache<Person>('/api/person')

The type Person becomes the generic type T, so we know that the object type returned from our API and stored in the cache will always be of type Person. At least during this function call.

Because we’ve used generics we can call this with whatever type and endpoint we need!

Adding some error handling

Finally, we need to make sure this will gracefully handle errors by updating our fetch a little:

const { data, error } = await useFetch<T>(url);

if (error.value) {
  throw createError({
    ...error.value,
    statusMessage: `Could not fetch data from ${url}`,
  });
}

Our entire useFetchWithCache composable is now complete!

// ~/composables/useFetchWithCache.ts

import { StorageSerializers } from '@vueuse/core';

export default async <T>(url: string) => {
  // Use sessionStorage to cache data
  const cached = useSessionStorage<T>(url, null, {
    serializer: StorageSerializers.object,
  });

  if (!cached.value) {
    const { data, error } = await useFetch<T>(url);

    if (error.value) {
      throw createError({
        ...error.value,
        statusMessage: `Could not fetch data from ${url}`,
      });
    }

    // Update the cache
    cached.value = data.value as T;
  } else {
    console.log(`Getting value from cache for ${url}`);
  }

  return cached;
};

Conclusion

Writing our own composables is a great way to leverage reusability and add on custom functionality to Nuxt.

Although caching doesn’t come with Nuxt by default, it’s not too difficult to add our own implementation. And writing it ourselves means that we get the maximum flexibility to tailor the behaviour to our app’s specific needs.

If you implement a version of this in your own app, let me know!

Michael Thiessen
Michael is a passionate full time Vue.js and Nuxt.js educator. His weekly newsletter is sent to over 11,000 Vue developers, and he has written over a hundred articles for his blog and VueSchool.

Follow MasteringNuxt on