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:
- Define query options with
defineQueryOptionsfrom@kimesh/query - Prefetch data in route loader using
queryClient.ensureQueryData() - 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:
// 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:
<!-- 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:
<!-- 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
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
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:
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:
// 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:
import { useLoaderData } from '@kimesh/router-runtime'
const data = useLoaderData<{ post: Post }>()
// data.value.postuseLayoutData
Access data from the parent layout's loader:
import { useLayoutData } from '@kimesh/router-runtime'
const layoutData = useLayoutData<{ categories: Category[] }>()
// layoutData.value?.categoriesuseMatchLoaderData
Access loader data from a specific route by path:
import { useMatchLoaderData } from '@kimesh/router-runtime'
const postsData = useMatchLoaderData<PostsData>('/posts')
// postsData.value if /posts route is matcheduseRouteLoaderData
Access all matched route loader data:
import { useRouteLoaderData } from '@kimesh/router-runtime'
const allData = useRouteLoaderData()
// allData.value = { '/posts': {...}, '/posts/:postId': {...} }Parallel Data Loading
Load multiple queries in parallel:
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),
])
},
})Prefetching Related Data
Prefetch data that might be needed soon:
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
| Strategy | Loader | Navigation | Loading State |
|---|---|---|---|
| Blocking | await ensureQueryData() | Waits for data | No skeleton needed |
| Non-blocking | ensureQueryData() (no await) | Instant | Suspense fallback |
| Hybrid | await critical + defer rest | Waits for critical | Suspense for deferred |
Strategy 1: Blocking (Current Default)
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) // InstantStrategy 2: Non-blocking with Suspense
export const Route = createFileRoute('/posts')({
loader: ({ context }) => {
// Fire & forget - doesn't block navigation
context.queryClient.ensureQueryData(postsQuery)
},
})<template>
<Suspense>
<PostsList />
<template #fallback>
<PostsSkeleton />
</template>
</Suspense>
</template><!-- 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.
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))
},
})<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:
<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:
<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 usingawait useSuspenseQuery()fallback- Loading state (skeleton)error- Error state with{ error, retry }
Search Params Validation
Validate and type search params with Zod:
<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:
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:
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
Define queries once, use everywhere - Query options in data files, used in both loaders and components
Use
ensureQueryDatain loaders - Waits for data if not cached, returns cached data if freshUse
prefetchQueryfor speculative loading - Fire-and-forget for data that might be neededColocate data files with routes - Use
-prefix to exclude from routingSet appropriate
staleTime- Prevents unnecessary refetches on navigation