Nuxt’s Data Layer Has Been Rewritten: 5 New Features You Need to Know

Discover the completely rewritten data fetching layer in Nuxt 3.17 featuring reactive keys, deduped watch calls, shared refs, granular caching, and improved developer experience. Learn how these performance enhancements make useAsyncData more powerful for your Vue applications.

Michael Thiessen
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

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

Click here to view course

One of the biggest features in Nuxt 3.17 is a complete rewrite of the data fetching layer, with significant performance and memory improvements, more consistent behaviour, and a couple new features.

At a glance, here's what's changed:

  • Reactive keys — use a getter, computed, or ref
  • Deduped watch calls — if different components use the same watch it will only trigger a single request
  • Shared refs — each key uses the same ref object across all useAsyncData calls
  • More granular caching — a new context object passed to getCachedData for better control
  • DX warnings — new warnings if you use conflicting options with the same key

We'll dive into each of these.

You can also check out the demo repo I created here.

Reactive Keys

The headline feature here is that we get reactive keys, which makes the useAsyncData composable far more powerful.

(I'm often talking about "reactifying" your composables by adding in as much reactivity as possible.)

Before we could only pass in a string as the key:

const { data } = useAsyncData('my-key', () =>
  $fetch('https://some-url')
)

But now, we can pass in any reactive object (as a MaybeRefOrGetter type):

// Getter (some function)
const { data } = useAsyncData(
  () => `key-${index + 1}`,
  () => $fetch('https://some-url')
)

// Ref
const myRefKey = ref('my-key')
const { data } = useAsyncData(myRefKey, () => $fetch('https://some-url'))

// Computed
const myComputedKey = computed(() => `my-key-${someOtherRef.value}`)
const { data } = useAsyncData(myComputedKey, () => $fetch('https://some-url'))

This gives us more flexibility in how our fetched data is stored in the payload:

// List of character IDs and names for the dropdown
const characters = [
  { id: 1, name: 'Luke Skywalker' },
  { id: 2, name: 'C-3PO' },
  { id: 3, name: 'R2-D2' },
  { id: 4, name: 'Darth Vader' },
  { id: 5, name: 'Leia Organa' },
]

const selectedId = ref(characters[0].id)

// Reactive key for useAsyncData
const characterKey = computed(
  () => `sw-character-${selectedId.value}`
)

const {
  data: character,
  pending,
  error,
  refresh,
} = await useAsyncData(characterKey, () =>
  $fetch(
    `https://swapi.py4e.com/api/people/${selectedId.value}/`
  )
)

screenshot of fetched data

If you click around a bit, you get a payload that looks like this:

screenshot of payload in devtools

You'll notice that we get to keep all the data we've fetched, because we have a unique key per character.

If we have to use a static string for the character, we don't get that:

// List of character IDs and names for the dropdown
const characters = [
  { id: 1, name: 'Luke Skywalker' },
  { id: 2, name: 'C-3PO' },
  { id: 3, name: 'R2-D2' },
  { id: 4, name: 'Darth Vader' },
  { id: 5, name: 'Leia Organa' },
]

const selectedId = ref(characters[0].id)

const {
  data: character,
  pending,
  error,
  refresh,
} = await useAsyncData('sw-character', () =>
  $fetch(
    `https://swapi.py4e.com/api/people/${selectedId.value}/`
  )
)

Every time we load a new character, the new data overwrites the old data, definitely not what we want!

screenshot of payload overriden in devtools

Deduped Watch Calls

Now, if there are two calls to useAsyncData for the same key that watch the same source, it will only trigger a single request:

// Component A
const { data: character } = await useAsyncData(
  'sw-character',
  () =>
    $fetch(
      `https://swapi.py4e.com/api/people/${selectedId.value}/`
    ),
  {
    watch: [selectedId],
  }
)

// Component B
const { data: character } = await useAsyncData(
  'sw-character',
  () =>
    $fetch(
      `https://swapi.py4e.com/api/people/${selectedId.value}/`
    ),
  {
    watch: [selectedId],
  }
)

Shared Refs

Before, each time you used useAsyncData or useFetch, you'd get back an entirely new ref object.

Now, these data fetching composables return the exact same objects, saving tons of memory and improving performance.

For example, if data is updated or mutated like this, it affects all components that are using this key:

function mutateData() {
  character.value = {
    name: 'Michael Thiessen',
    height: 180,
    mass: 70,
    hair_color: 'blonde',
    skin_color: 'fair',
    eye_color: 'blue',
    birth_year: '1993',
    gender: 'male',
  }
}

screenshot showing data staying in sync

This prevents a lot of bugs around inconsistency as well, and makes it much easier to rely on Nuxt's built-in composables for lightweight state management before reaching for Pinia.

Check out the docs for useAsyncData here.

Granular Caching

In useAsyncData we can pass in an optional method, getCachedData, that checks to see if we should fetch new data or rely on what's already in our cache:

const { data: character } = await useAsyncData(
  'sw-character',
  () =>
    $fetch(
      `https://swapi.py4e.com/api/people/${selectedId.value}/`
    ),
  {
    // Check to see if we already have the data in our payload
    getCachedData: (key, nuxtApp) => {
      return (
        nuxtApp.payload.data[key] ||
        nuxtApp.static.data[key]
      )
    },
  }
)

In Nuxt 3.17 we get a new context object passed in, so we have more information on why a fetch is happening:

getCachedData: (key, nuxtApp, context) => {
  if (context.cause === 'refresh:manual') {
    // Always refetch data when `refresh` is called
    return null
  }

  return (
    nuxtApp.payload.data[key] ||
    nuxtApp.static.data[key]
  )
},

Currently, there are four different reasons a fetch could be triggered:

  • Initial fetch — initial
  • Triggered from a watcher — watch
  • From calling refresh — refresh:manual
  • Refresh from a hook — refresh:hook

Here's the type that gets passed in:

type AsyncDataRequestContext = {
  /** The reason for this data request */
  cause:
    | 'initial'
    | 'refresh:manual'
    | 'refresh:hook'
    | 'watch'
}

This one is disabled by default because it is a breaking change, unless you have v4 compatibility turned on. You can also turn it on through the experimental config flag:

export default defineNuxtConfig({
  experimental: {
    granularCachedData: true,
  },
})

DX Warnings

Lastly, we get a few nice DX improvements that are necessary now that calls with the same key are sharing the same underlying object.

Some options conflict because of the way that the data is fetched, and Nuxt will now warn you about these:

  • handler
  • deep
  • transform
  • pick
  • getCachedData
  • default

But there are several options that can be different, because they don't affect the underlying object:

  • server
  • lazy
  • immediate
  • dedupe
  • watch

What code was changed?

This rewrite is pretty substantial, and you can check out the pull request for the full details.

Here's a full list of the bugs squashed in that PR:

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