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:
| Property | Meaning | Type |
|---|---|---|
| data | Query result | Ref<T> |
| isLoading | Loading state | Ref<boolean> |
| error | Error info | Ref<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.