Skip to content

Data Loading

Kimesh provides data loading through createFileRoute with TanStack Query integration for fetching data before routes render.

Overview

The data loading pattern in Kimesh:

  1. Define query options with defineQueryOptions from @kimesh/query
  2. Prefetch data in route loader using queryClient.ensureQueryData()
  3. Access cached data in component with useQuery()

This pattern ensures data is ready before navigation completes, providing instant page loads.

Basic Example

1. Define Query Options

Create a data file with query definitions:

ts
// routes/posts/posts.data.ts
import { defineQueryOptions, createQueryKeyFactory } from '@kimesh/query'

const api = {
  posts: {
    getAll: () => fetch('/api/posts').then((r) => r.json()),
    getById: (id: string) => fetch(`/api/posts/${id}`).then((r) => r.json()),
  },
}

// Create type-safe query keys
export const postKeys = createQueryKeyFactory('posts', {
  list: null,
  detail: (id: string) => id,
})

// Define query options
export const postsListQuery = defineQueryOptions({
  key: postKeys.list(),
  query: () => api.posts.getAll(),
  staleTime: 5 * 60 * 1000, // 5 minutes
})

export const postDetailQuery = (id: string) =>
  defineQueryOptions({
    key: postKeys.detail(id),
    query: () => api.posts.getById(id),
    staleTime: 10 * 60 * 1000,
  })

2. Prefetch in Route Loader

Use createFileRoute to define a loader that prefetches data:

template
<!-- routes/posts/index.vue -->
<script lang="ts">
import { createFileRoute } from '@kimesh/router-runtime'
import { postsListQuery } from './posts.data'

export const Route = createFileRoute('/posts')({
  loader: async ({ context }) => {
    // Prefetch data before navigation completes
    await context.queryClient.ensureQueryData(postsListQuery)
  },
  meta: {
    title: 'Posts',
  },
})
</script>

<script setup lang="ts">
import { useQuery } from '@kimesh/query'
import { postsListQuery } from './posts.data'

// Data is already cached - instant access!
const { data: posts } = useQuery(postsListQuery)
</script>

<template>
  <div>
    <h1>Posts</h1>
    <article v-for="post in posts" :key="post.id">
      <h2>{{ post.title }}</h2>
    </article>
  </div>
</template>

3. Dynamic Route Parameters

For dynamic routes, pass params to the query:

template
<!-- routes/posts/$postId.vue -->
<script lang="ts">
import { createFileRoute } from '@kimesh/router-runtime'
import { postDetailQuery } from './posts.data'

export const Route = createFileRoute('/posts/:postId')({
  loader: async ({ params, context }) => {
    await context.queryClient.ensureQueryData(postDetailQuery(params.postId))
  },
})
</script>

<script setup lang="ts">
import { useQuery } from '@kimesh/query'
import { useReactiveParams } from '@kimesh/router-runtime'
import { postDetailQuery } from './posts.data'

const params = useReactiveParams<'/posts/:postId'>()
const { data: post } = useQuery(postDetailQuery(params.value.postId))
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <p>{{ post.body }}</p>
  </article>
</template>

createFileRoute API

ts
import { createFileRoute } from '@kimesh/router-runtime'

export const Route = createFileRoute('/path')({
  // Loader runs before navigation completes
  loader: async ({ params, context, signal }) => {
    await context.queryClient.ensureQueryData(myQuery)
    return { data: 'returned from loader' }
  },

  // Run before loader to accumulate context
  beforeLoad: async ({ params, context }) => {
    const user = await fetchUser()
    return { user } // Merged into context for loader
  },

  // Route metadata
  meta: {
    title: 'Page Title',
    description: 'Page description',
  },

  // Middleware (string, array, or inline function)
  middleware: ['auth', 'analytics'],

  // Search params validation (Zod compatible)
  validateSearch: z.object({
    page: z.number().default(1),
    sort: z.enum(['asc', 'desc']).default('desc'),
  }),

  // Head/SEO configuration
  head: {
    title: 'Page Title',
    meta: [{ name: 'description', content: '...' }],
  },

  // Or dynamic head based on loader data
  head: ({ params, loaderData }) => ({
    title: loaderData.post.title,
  }),

  // Loading state configuration
  pendingComponent: () => import('./PostSkeleton.vue'),
  pendingMs: 200, // Delay before showing loading (default: 200)
  pendingMinMs: 500, // Minimum loading display time (default: 500)

  // Transitions
  transition: { name: 'fade' },
  viewTransition: true, // Browser View Transitions API
  keepalive: true,
})

Loader Context

ts
interface LoaderContext {
  // Route params (e.g., { postId: '123' })
  params: Record<string, string>

  // Validated search/query params
  search: Record<string, unknown>

  // App context (includes queryClient + beforeLoad returns)
  context: {
    queryClient: QueryClient
    // Plus any data returned from beforeLoad
  }

  // AbortSignal for cancellation
  signal: AbortSignal
}

beforeLoad Hook

The beforeLoad hook runs before the loader and can add data to the context:

ts
export const Route = createFileRoute('/posts/:postId')({
  beforeLoad: async ({ params, context }) => {
    // Fetch data needed by loader
    const permissions = await checkPermissions(params.postId)
    return { permissions } // Added to context
  },

  loader: async ({ params, context }) => {
    // context.permissions is available here
    if (!context.permissions.canView) {
      throw new Error('Unauthorized')
    }
    return context.queryClient.ensureQueryData(postQuery(params.postId))
  },
})

Context Accumulation in Layouts

Parent layout beforeLoad data is available to child routes:

ts
// routes/posts.vue (layout)
export const Route = createFileRoute('/posts')({
  beforeLoad: async ({ context }) => {
    const categories = await fetchCategories()
    return { categories }
  },
})

// routes/posts/$postId.vue (child)
export const Route = createFileRoute('/posts/:postId')({
  loader: async ({ context }) => {
    // context.categories from parent layout is available
    console.log(context.categories)
  },
})

Accessing Loader Data

useLoaderData

Access data returned from the current route's loader:

ts
import { useLoaderData } from '@kimesh/router-runtime'

const data = useLoaderData<{ post: Post }>()
// data.value.post

useLayoutData

Access data from the parent layout's loader:

ts
import { useLayoutData } from '@kimesh/router-runtime'

const layoutData = useLayoutData<{ categories: Category[] }>()
// layoutData.value?.categories

useMatchLoaderData

Access loader data from a specific route by path:

ts
import { useMatchLoaderData } from '@kimesh/router-runtime'

const postsData = useMatchLoaderData<PostsData>('/posts')
// postsData.value if /posts route is matched

useRouteLoaderData

Access all matched route loader data:

ts
import { useRouteLoaderData } from '@kimesh/router-runtime'

const allData = useRouteLoaderData()
// allData.value = { '/posts': {...}, '/posts/:postId': {...} }

Parallel Data Loading

Load multiple queries in parallel:

ts
export const Route = createFileRoute('/dashboard')({
  loader: async ({ context }) => {
    const { queryClient } = context

    // Fetch all data in parallel
    await Promise.all([
      queryClient.ensureQueryData(userQuery),
      queryClient.ensureQueryData(statsQuery),
      queryClient.ensureQueryData(notificationsQuery),
    ])
  },
})

Prefetch data that might be needed soon:

ts
export const Route = createFileRoute('/posts')({
  loader: async ({ context }) => {
    const { queryClient } = context

    // Wait for main data
    const posts = await queryClient.ensureQueryData(postsListQuery)

    // Prefetch first few post details in background (don't await)
    posts.slice(0, 3).forEach((post) => {
      queryClient.prefetchQuery(postDetailQuery(post.id))
    })
  },
})

Suspense & Deferred Loading

Kimesh supports Vue's <Suspense> for progressive loading with skeleton UI.

Loading Strategies

StrategyLoaderNavigationLoading State
Blockingawait ensureQueryData()Waits for dataNo skeleton needed
Non-blockingensureQueryData() (no await)InstantSuspense fallback
Hybridawait critical + defer restWaits for criticalSuspense for deferred

Strategy 1: Blocking (Current Default)

ts
export const Route = createFileRoute('/posts')({
  loader: async ({ context }) => {
    // Blocks navigation until data is ready
    await context.queryClient.ensureQueryData(postsQuery)
  },
})

// Component - data already in cache
const { data } = useQuery(postsQuery) // Instant

Strategy 2: Non-blocking with Suspense

ts
export const Route = createFileRoute('/posts')({
  loader: ({ context }) => {
    // Fire & forget - doesn't block navigation
    context.queryClient.ensureQueryData(postsQuery)
  },
})
vue
<template>
  <Suspense>
    <PostsList />
    <template #fallback>
      <PostsSkeleton />
    </template>
  </Suspense>
</template>
vue
<!-- PostsList.vue -->
<script setup>
import { useSuspenseQuery } from '@kimesh/query'

// Suspends until data ready, then renders
const { data: posts } = useSuspenseQuery(postsQuery)
</script>

Strategy 3: Hybrid - Critical + Deferred

Best of both worlds: critical data blocks, non-critical loads progressively.

ts
import { defer } from '@kimesh/query'

export const Route = createFileRoute('/users/:userId')({
  loader: async ({ params, context }) => {
    const { queryClient } = context

    // Critical: blocks navigation
    await queryClient.ensureQueryData(userQuery(params.userId))

    // Deferred: starts fetching, doesn't block
    defer(queryClient, userActivityQuery(params.userId), userRecommendationsQuery(params.userId))
  },
})
vue
<template>
  <!-- Critical data - renders immediately -->
  <UserHeader :user="user" />

  <!-- Deferred with KmDeferred (Suspense + error handling) -->
  <KmDeferred>
    <UserActivity :user-id="user.id" />
    <template #fallback>
      <ActivitySkeleton />
    </template>
    <template #error="{ error, retry }">
      <ErrorCard :error="error" @retry="retry" />
    </template>
  </KmDeferred>
</template>

Using useSuspenseQuery

useSuspenseQuery uses top-level await to trigger Vue's Suspense:

vue
<script setup>
import { useSuspenseQuery } from '@kimesh/query'
import { userActivityQuery } from './-users.data'

const props = defineProps<{ userId: string }>()

// Top-level await triggers Suspense - component suspends until data ready
const { data: activity } = await useSuspenseQuery(userActivityQuery(props.userId))
</script>

<template>
  <div v-for="item in activity" :key="item.id">
    {{ item.action }}
  </div>
</template>

KmDeferred Component

<KmDeferred> wraps Vue's <Suspense> with error handling:

vue
<KmDeferred :timeout="200">
  <AsyncComponent />
  
  <template #fallback>
    <Skeleton />
  </template>
  
  <template #error="{ error, retry }">
    <div>
      <p>{{ error.message }}</p>
      <button @click="retry">Try again</button>
    </div>
  </template>
</KmDeferred>

Props:

  • timeout - Delay before showing fallback (default: 0)

Slots:

  • default - Component using await useSuspenseQuery()
  • fallback - Loading state (skeleton)
  • error - Error state with { error, retry }

Search Params Validation

Validate and type search params with Zod:

template
<script lang="ts">
import { createFileRoute } from '@kimesh/router-runtime'
import { z } from 'zod'

const searchSchema = z.object({
  page: z.coerce.number().default(1),
  sort: z.enum(['newest', 'oldest']).default('newest'),
  tag: z.string().optional(),
})

export const Route = createFileRoute('/posts')({
  validateSearch: searchSchema,
  loader: async ({ search, context }) => {
    // search is typed as { page: number, sort: 'newest' | 'oldest', tag?: string }
    await context.queryClient.ensureQueryData(
      postsListQuery({ page: search.page, sort: search.sort })
    )
  },
})
</script>

<script setup lang="ts">
import { useSearch } from '@kimesh/router-runtime'

// Type-safe search params
const search = useSearch<'/posts'>()
console.log(search.value.page) // number
</script>

Error Handling

Errors thrown in loaders are caught and can be handled:

ts
export const Route = createFileRoute('/posts/:postId')({
  loader: async ({ params, context }) => {
    try {
      await context.queryClient.ensureQueryData(postDetailQuery(params.postId))
    } catch (error) {
      // Error is stored in route meta as __kimeshError
      // Can be accessed in error boundaries
      throw error
    }
  },
})

Colocating Data Files

Keep query definitions close to routes using the - prefix (excluded from routing):

routes/
├── posts/
│   ├── index.vue           # /posts
│   ├── $postId.vue         # /posts/:postId
│   ├── -posts.data.ts      # Query definitions (excluded)
│   └── -types.ts           # TypeScript types (excluded)

Query Key Factory

Use createQueryKeyFactory for organized, type-safe query keys:

ts
import { createQueryKeyFactory } from '@kimesh/query'

export const postKeys = createQueryKeyFactory('posts', {
  list: null, // ['posts', 'list']
  detail: (id: string) => id, // ['posts', 'detail', id]
  byAuthor: (authorId: string) => authorId, // ['posts', 'byAuthor', authorId]
})

// Usage
postKeys.list() // ['posts', 'list']
postKeys.detail('123') // ['posts', 'detail', '123']
postKeys.byAuthor('456') // ['posts', 'byAuthor', '456']

Best Practices

  1. Define queries once, use everywhere - Query options in data files, used in both loaders and components

  2. Use ensureQueryData in loaders - Waits for data if not cached, returns cached data if fresh

  3. Use prefetchQuery for speculative loading - Fire-and-forget for data that might be needed

  4. Colocate data files with routes - Use - prefix to exclude from routing

  5. Set appropriate staleTime - Prevents unnecessary refetches on navigation

Released under the MIT License.