Traditional approach causes a loading flash on every navigation:
List Page Click Detail Page
[Post 1] --> Loading... --> [Post 1 Full]
[Post 2] ^
[Post 3] ~150-300ms wait
Reuse cached data for instant navigation:
List Page Click Detail Page
[Post 1] --> [Post 1] --> [Post 1 Full]
[Post 2] (instant!) |
[Post 3] Content loads in background
User sees title, thumbnail, description instantly, then full content loads via WebSocket.
<!-- pages/posts/index.vue -->
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
// This query populates the cache
const { data: posts } = await useConvexQuery(api.posts.list, {})
</script>
<template>
<div class="posts-grid">
<NuxtLink
v-for="post in posts"
:key="post._id"
:to="`/posts/${post.slug}`"
>
<img :src="post.thumbnail" :alt="post.title" />
<h2>{{ post.title }}</h2>
<p>{{ post.description }}</p>
</NuxtLink>
</div>
</template>
// convex/posts.ts
// List query - lightweight, no content
export const list = query({
handler: async (ctx) => {
const posts = await ctx.db.query('posts').collect()
// Exclude heavy content field for list view
return posts.map(({ content, ...rest }) => rest)
},
})
// Detail query - full post with content
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, { slug }) => {
return await ctx.db
.query('posts')
.withIndex('by_slug', q => q.eq('slug', slug))
.first()
},
})
<!-- pages/posts/[slug].vue -->
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const route = useRoute()
const slug = computed(() => route.params.slug as string)
// The key pattern: lazy + default from cache
const { data: post, pending } = await useConvexQuery(
api.posts.getBySlug,
computed(() => ({ slug: slug.value })),
{
lazy: true, // Don't block navigation
// Provide cached data instantly
default: () => {
return useConvexCached(api.posts.list, {})?.find(p => p.slug === slug.value)
}
}
)
</script>
<template>
<article v-if="post">
<!-- These show instantly from cache -->
<h1>{{ post.title }}</h1>
<img :src="post.thumbnail" :alt="post.title" />
<p class="description">{{ post.description }}</p>
<!-- Content loads in background -->
<div v-if="post.content" class="content">
{{ post.content }}
</div>
<div v-else class="loading-content">
Loading article...
</div>
</article>
</template>
The useConvexCached composable reads from the query cache:
import { api } from '~/convex/_generated/api'
// Read cached posts from a previous query
const cachedPosts = useConvexCached(api.posts.list, {})
// Find a specific post
const cachedPost = cachedPosts?.find(p => p.slug === 'my-post')
useConvexCached reads from Nuxt's useState cache using the same key format as useConvexQuery:
convex:${functionName}:${stringifiedArgs}
This means:
useConvexQuery call fetched itArgs must match exactly for cache to hit:
// These are DIFFERENT cache keys:
useConvexQuery(api.posts.list, {})
useConvexQuery(api.posts.list, { category: 'tech' })
// Reading cache:
useConvexCached(api.posts.list, {}) // Only matches first
useConvexCached(api.posts.list, { category: 'tech' }) // Only matches second
Use transform to add computed fields to both cached and fetched data:
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
const { data: post } = await useConvexQuery(
api.posts.getBySlug,
computed(() => ({ slug: slug.value })),
{
lazy: true,
// Default with computed fields
default: () => {
const cached = useConvexCached(api.posts.list, {})?.find(p => p.slug === slug.value)
return cached ? {
...cached,
formattedDate: formatDate(cached.publishedAt),
} : undefined
},
// Transform also adds computed fields
transform: (post) => post ? {
...post,
formattedDate: formatDate(post.publishedAt),
} : undefined,
}
)
Now post.formattedDate is available immediately from cache and updates when full data loads.
When users navigate directly to a detail page (bookmark, shared link), the cache is empty:
default: () => {
const cached = useConvexCached(api.posts.list, {})
return cached?.find(p => p.slug === slug.value)
// Returns undefined if cache miss
}
Handle this gracefully in the template:
<template>
<!-- Shows loading if no cached data -->
<div v-if="pending && !post" class="loading">
<Skeleton />
</div>
<!-- Shows content when available -->
<article v-else-if="post">
...
</article>
</template>
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const route = useRoute()
const slug = computed(() => route.params.slug as string)
function formatDate(ts: number) {
return new Date(ts).toLocaleDateString()
}
function calculateReadingTime(content?: string) {
if (!content) return '...'
return Math.ceil(content.split(' ').length / 200) + ' min'
}
const { data: post, pending } = await useConvexQuery(
api.posts.getBySlug,
computed(() => ({ slug: slug.value })),
{
lazy: true,
default: () => {
const cached = useConvexCached(api.posts.list, {})?.find(
p => p.slug === slug.value
)
return cached ? {
...cached,
formattedDate: formatDate(cached.publishedAt),
readingTime: '...', // Unknown from cache
} : undefined
},
transform: (post) => post ? {
...post,
formattedDate: formatDate(post.publishedAt),
readingTime: calculateReadingTime(post.content),
} : undefined,
}
)
</script>
<template>
<!-- Full loading state (direct navigation) -->
<div v-if="pending && !post" class="loading">
<div class="skeleton-title" />
<div class="skeleton-image" />
<div class="skeleton-text" />
</div>
<!-- Post content -->
<article v-else-if="post">
<!-- Instant from cache -->
<h1>{{ post.title }}</h1>
<div class="meta">
<span>{{ post.formattedDate }}</span>
<span>{{ post.readingTime }}</span>
</div>
<img :src="post.thumbnail" :alt="post.title" />
<p class="description">{{ post.description }}</p>
<!-- Loads in background -->
<div v-if="post.content" class="content">
<p v-for="p in post.content.split('\n\n')" :key="p">{{ p }}</p>
</div>
<div v-else class="loading-content">
Loading article content...
</div>
</article>
</template>
1. LIST PAGE LOADS
useConvexQuery(api.posts.list, {})
-> Fetches posts via SSR/WebSocket
-> Stores in useState('convex:posts.list:{}')
2. USER CLICKS POST
NuxtLink navigates to /posts/my-slug
3. DETAIL PAGE MOUNTS
useConvexQuery(api.posts.getBySlug, { slug })
-> lazy: true = Don't block, render immediately
-> default() runs:
-> useConvexCached(api.posts.list, {})
-> Reads from useState (cache hit!)
-> .find(p => p.slug === slug)
-> data.value = cached post (instant!)
4. WEBSOCKET SUBSCRIPTION
-> Fetches full post with content
-> transform() runs on result
-> data.value updates with full post
lazy: true on detail pages for instant navigationdefault and transformlazy: true| Metric | Without Pattern | With Pattern |
|---|---|---|
| Navigation time | ~150-300ms | ~0ms (instant) |
| Time to first paint | After fetch | Immediate |
| Loading flash | Yes | No (for cached fields) |
| Data freshness | Always fresh | Cache -> Fresh |
Read cached data from a previous query.
const cached = useConvexCached(query, args)
| Parameter | Type | Description |
|---|---|---|
query | FunctionReference<"query"> | Convex query function reference |
args | FunctionArgs<Query> | Query arguments (must match exactly) |
Returns: T | undefined - Cached data if available, undefined if not cached
Like useConvexCached but returns a reactive Ref:
// useConvexCached - returns plain value
const cached = useConvexCached(api.posts.list, {})
// cached is Post[] | undefined
// useConvexData - returns Ref
const cachedRef = useConvexData(api.posts.list, {})
// cachedRef is Ref<Post[] | undefined>
Use useConvexCached in most cases. Use useConvexData when you need Vue reactivity on the cached value.
default and transform