Data Fetching

Caching & Data Reuse

Instant navigation with cached data reuse between pages.

The Problem

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

The Solution

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.


Implementation

Step 1: List Page (Populates Cache)

<!-- 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>

Step 2: Convex Queries

// 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()
  },
})

Step 3: Detail Page (Reuses Cache)

<!-- 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>

Reading Cached Data

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')

How Caching Works

useConvexCached reads from Nuxt's useState cache using the same key format as useConvexQuery:

convex:${functionName}:${stringifiedArgs}

This means:

  • Data is only available if a previous useConvexQuery call fetched it
  • Args must match exactly (same values, same order)
  • Cache persists during client-side navigation
  • Cache is cleared on hard refresh (new SSR)

Cache Key Matching

Args 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

Adding Computed Fields

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.


Handling Direct Navigation

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>

Complete Example

<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>

How It Works

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

Best Practices

Do

  • Use lazy: true on detail pages for instant navigation
  • Keep list query lightweight (exclude large fields)
  • Apply same computed fields in both default and transform
  • Handle the "no cache" case gracefully

Don't

  • Block navigation by omitting lazy: true
  • Include heavy content in list queries
  • Forget to transform cached data the same way as fetched data
  • Assume cache is always available

Performance Benefits

MetricWithout PatternWith Pattern
Navigation time~150-300ms~0ms (instant)
Time to first paintAfter fetchImmediate
Loading flashYesNo (for cached fields)
Data freshnessAlways freshCache -> Fresh

API Reference

useConvexCached

Read cached data from a previous query.

const cached = useConvexCached(query, args)
ParameterTypeDescription
queryFunctionReference<"query">Convex query function reference
argsFunctionArgs<Query>Query arguments (must match exactly)

Returns: T | undefined - Cached data if available, undefined if not cached

useConvexData

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.