Skip to content

TanStack Data Fetching: Query vs Router Loaders

Disponible en français

With TanStack Router/Start, React Query becomes optional for read-only data. Use loaders for simplicity, Query for reactivity.


Data TypeUseWhy
Stable/StaticRouter loaderBlog posts, product pages, profiles — rarely changes. Built-in loading/error states.
Dynamic/MutableReact QueryEditors, real-time feeds, collaborative data — needs cache invalidation, optimistic updates, refetching.
HybridBothLoader for initial fetch, Query for mutations/revalidation.

Is this data mutated frequently?
→ YES → React Query
→ NO → Does it need background refetching?
→ YES → React Query
→ NO → Router loader

Best for: blog posts, static pages, product details

export const Route = createFileRoute('/post/$id')({
loader: ({ params }) => fetchPost(params.id),
component: PostPage,
})
function PostPage() {
const post = Route.useLoaderData()
return <article>{post.content}</article>
}

Pros: Simple, built-in loading/error states, SSR-friendly Cons: No automatic revalidation, no optimistic updates

Best for: dashboards, editors, real-time data

export const Route = createFileRoute('/dashboard')({
component: Dashboard,
})
function Dashboard() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
refetchInterval: 30000, // poll every 30s
})
if (isLoading) return <Skeleton />
return <StatsGrid data={data} />
}

Pros: Auto-revalidation, cache control, optimistic updates, devtools Cons: More boilerplate, manual loading states

Best for: detail pages with mutations, data that’s stable until user edits

export const Route = createFileRoute('/post/$id')({
loader: ({ params }) => fetchPost(params.id),
component: PostEditor,
})
function PostEditor() {
const initialData = Route.useLoaderData()
const { id } = Route.useParams()
// Query with loader data as initial value
const { data: post } = useQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id),
initialData, // Fast initial load from loader
})
// Mutations with optimistic updates
const updateMutation = useMutation({
mutationFn: updatePost,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['post', id] })
const previous = queryClient.getQueryData(['post', id])
queryClient.setQueryData(['post', id], newData)
return { previous }
},
onError: (err, _, context) => {
queryClient.setQueryData(['post', id], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['post', id] })
},
})
return <Editor post={post} onSave={updateMutation.mutate} />
}

Pros: Fast initial load + full reactivity + optimistic updates Cons: Most complex setup


ScenarioRecommendation
Blog post viewLoader
Product catalog browseLoader
User profile view (own)Hybrid (view: loader, edit: query)
Dashboard with live statsQuery
Document editorQuery
Comments sectionQuery (needs add/delete/edit)
Settings pageHybrid
Search resultsQuery (needs instant feedback)
Static marketing pagesLoader

// Overkill for static content
const { data } = useQuery({
queryKey: ['about-page'],
queryFn: fetchAboutContent,
})
export const Route = createFileRoute('/about')({
loader: () => fetchAboutContent(),
})
// User can't see updates without page refresh
export const Route = createFileRoute('/notifications')({
loader: () => fetchNotifications(),
})
const { data } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 10000,
})

  • Query isn’t dead — it’s now optional for read-only stable data
  • Loader: simple, SSR-friendly, no reactivity
  • Query: complex, reactive, optimistic updates
  • Hybrid: fast initial + full control

When in doubt: start with loader, upgrade to hybrid when you need mutations.