Why Your Vue Query Data Doesn't Show Up in Nuxt 3 (the ref unwrapping trap)

Data logs fine in the console but never renders — a walkthrough of why v-if and v-for don't auto-unwrap refs returned by @tanstack/vue-query, and how to fix it.

Why Your Vue Query Data Doesn't Show Up in Nuxt 3 (the ref unwrapping trap)

While wiring up @tanstack/vue-query in a Nuxt 3 project to fetch API data, I ran into a frustrating bug: the data showed up perfectly in console.log, but nothing rendered on the page.

This post covers why that happens and how Vue 3's ref unwrapping actually works inside templates — because the fix is one character, but understanding why it's needed is the useful part.


The symptom: data exists, but the screen shows nothing

<template>
  <div v-if="postsQuery.data && postsQuery.data.length > 0">
    <ul>
      <li v-for="post in postsQuery.data" :key="post.id">
        {{ post.title }}
      </li>
    </ul>
  </div>
  <p v-else>No data.</p>
</template>

This looks completely reasonable. And console.log(postsQuery.data) confirms the data is really there:

[
  { "id": 1, "title": "Hello Vue Query" },
  { "id": 2, "title": "Nuxt 3 is Awesome" }
]

But the page only ever shows "No data." Why?


Root cause: useQuery() returns refs, not plain values

vue-query's useQuery() is built on Vue's reactivity system, and every property it returns is a ref:

PropertyMeaningType
dataQuery resultRef<T>
isLoadingLoading stateRef<boolean>
errorError infoRef<Error>

So postsQuery.data is not an array — it's a Ref<Array>.


The actual bug: template auto-unwrapping doesn't apply inside directives

Vue 3 templates automatically unwrap top-level refs, which is why {{ postsQuery.data }} "just works" in interpolation.

But that auto-unwrapping does not apply inside directive expressions like v-if and v-for. This means the following silently does nothing useful:

<!-- Broken -->
<div v-if="postsQuery.data && postsQuery.data.length > 0"> ... </div>

postsQuery.data is still a ref object here, so .length is undefined, and the condition evaluates to false — every time, regardless of whether data actually arrived.


The fix: access .value explicitly

<!-- Fixed -->
<div v-if="postsQuery.data?.value?.length > 0">
  <ul>
    <li v-for="post in postsQuery.data.value" :key="post.id">
      {{ post.title }}
    </li>
  </ul>
</div>
<p v-else>No data.</p>

Cleaner version: pull it into a computed

Repeating .value everywhere in the template gets noisy fast. Extracting it into a computed keeps things readable:

<script setup>
import { usePosts } from '~/composables/usePosts'
import { computed } from 'vue'

const { fetchPostsQuery } = usePosts()
const postsQuery = fetchPostsQuery()

const posts = computed(() => postsQuery.data.value ?? [])
const isLoading = computed(() => postsQuery.isLoading.value)
const error = computed(() => postsQuery.error.value)
</script>
<template>
  <div>
    <p v-if="isLoading">Loading...</p>
    <p v-else-if="error">Error: {{ error.message }}</p>
    <div v-else-if="posts.length > 0">
      <ul>
        <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
      </ul>
    </div>
    <p v-else>No data.</p>
  </div>
</template>

The composable behind it

// ~/composables/usePosts.ts
import { useNuxtApp } from '#app'

export const usePostApi = () => {
  const axios = useNuxtApp().$axios
  return {
    fetchPosts: async () => {
      const response = await axios.get('/posts')
      return response.data
    }
  }
}

export const usePosts = () => {
  const api = usePostApi()
  const fetchPostsQuery = () => useQuery({
    queryKey: ['posts'],
    queryFn: api.fetchPosts
  })
  return { fetchPostsQuery }
}

Takeaway

  • useQuery() returns every piece of state as a ref.
  • Template interpolation ({{ }}) auto-unwraps refs, but v-if / v-for expressions do not — you need .value explicitly.
  • Pulling the unwrapped values into computed properties keeps the template clean and avoids this trap entirely.