Client-Side Data Fetching with React Query
Use React Query (TanStack Query) for client-side data fetching in MakerKit. Covers queries, mutations, caching, optimistic updates, and combining with Server Components.
React Query (TanStack Query v5) manages client-side data fetching with automatic caching, background refetching, and optimistic updates. MakerKit includes it pre-configured. Use React Query when you need real-time dashboards, infinite scroll, optimistic UI updates, or data shared across multiple components. For initial page loads, prefer route loaders. Tested with TanStack Query v5 (uses gcTime instead of cacheTime).
When to use React Query and route loaders?
Use React Query for real-time updates, optimistic mutations, pagination, and shared client-side state.
Use route loaders for initial page loads and SEO content. Combine both: load data in the route loader via a server function, then hydrate React Query for client interactivity.
When to Use React Query
Use React Query for:
- Real-time dashboards that need background refresh
- Infinite scroll and pagination
- Data that multiple components share
- Optimistic updates for instant feedback
- Client-side filtering and sorting with server data
Use route loaders instead for:
- Initial page loads
- SEO-critical content
- Data that doesn't need real-time updates
Basic Query
Fetch data with useQuery. The query automatically caches results and handles loading/error states:
import { useQuery } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';export function TasksList({ accountId }: { accountId: string }) { const supabase = useSupabase(); const { data: tasks, isLoading, error } = useQuery({ queryKey: ['tasks', accountId], queryFn: async () => { const { data, error } = await supabase .from('tasks') .select('*') .eq('account_id', accountId) .order('created_at', { ascending: false }); if (error) throw error; return data; }, }); if (isLoading) { return <div>Loading tasks...</div>; } if (error) { return <div>Failed to load tasks</div>; } return ( <ul> {tasks?.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> );}Query Keys
Query keys identify cached data. Structure them hierarchically for easy invalidation:
// Specific taskqueryKey: ['tasks', taskId]// All tasks for an accountqueryKey: ['tasks', { accountId }]// All tasks for an account with filtersqueryKey: ['tasks', { accountId, status: 'pending', page: 1 }]// Invalidate all task queriesqueryClient.invalidateQueries({ queryKey: ['tasks'] });// Invalidate tasks for specific accountqueryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] });Query Key Factory
For larger apps, create a query key factory:
// lib/query-keys.tsexport const queryKeys = { tasks: { all: ['tasks'] as const, list: (accountId: string) => ['tasks', { accountId }] as const, detail: (taskId: string) => ['tasks', taskId] as const, filtered: (accountId: string, filters: TaskFilters) => ['tasks', { accountId, ...filters }] as const, }, members: { all: ['members'] as const, list: (accountId: string) => ['members', { accountId }] as const, },};// Usageconst { data } = useQuery({ queryKey: queryKeys.tasks.list(accountId), queryFn: () => fetchTasks(accountId),});// Invalidate all tasksqueryClient.invalidateQueries({ queryKey: queryKeys.tasks.all });Mutations
Use useMutation for create, update, and delete operations:
import { useMutation, useQueryClient } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';export function CreateTaskForm({ accountId }: { accountId: string }) { const supabase = useSupabase(); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (newTask: { title: string }) => { const { data, error } = await supabase .from('tasks') .insert({ title: newTask.title, account_id: accountId, }) .select() .single(); if (error) throw error; return data; }, onSuccess: () => { // Invalidate and refetch tasks list queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] }); }, }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutation.mutate({ title: formData.get('title') as string }); }; return ( <form onSubmit={handleSubmit}> <input name="title" placeholder="Task title" required /> <button type="submit" disabled={mutation.isPending}> {mutation.isPending ? 'Creating...' : 'Create Task'} </button> {mutation.error && ( <p className="text-destructive">Failed to create task</p> )} </form> );}Optimistic Updates
Update the UI immediately before the server responds for a snappier feel:
import { useMutation, useQueryClient } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';export function useUpdateTask(accountId: string) { const supabase = useSupabase(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (task: { id: string; completed: boolean }) => { const { data, error } = await supabase .from('tasks') .update({ completed: task.completed }) .eq('id', task.id) .select() .single(); if (error) throw error; return data; }, // Optimistically update the cache onMutate: async (updatedTask) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['tasks', { accountId }], }); // Snapshot previous value const previousTasks = queryClient.getQueryData<Task[]>([ 'tasks', { accountId }, ]); // Optimistically update queryClient.setQueryData<Task[]>( ['tasks', { accountId }], (old) => old?.map((task) => task.id === updatedTask.id ? { ...task, completed: updatedTask.completed } : task ) ); // Return context with snapshot return { previousTasks }; }, // Rollback on error onError: (err, updatedTask, context) => { queryClient.setQueryData( ['tasks', { accountId }], context?.previousTasks ); }, // Always refetch after error or success onSettled: () => { queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }], }); }, });}Usage:
function TaskItem({ task, accountId }: { task: Task; accountId: string }) { const updateTask = useUpdateTask(accountId); return ( <label className="flex items-center gap-2"> <input type="checkbox" checked={task.completed} onChange={(e) => updateTask.mutate({ id: task.id, completed: e.target.checked }) } /> <span className={task.completed ? 'line-through' : ''}> {task.title} </span> </label> );}Combining with Route Loaders
Load initial data in a route loader via a server function, then hydrate React Query for client-side updates:
// apps/web/src/routes/_authenticated/dashboard/tasks.tsximport { createFileRoute } from '@tanstack/react-router';import { fetchTasks } from '#/lib/server/tasks.functions';import { TasksManager } from './tasks-manager';export const Route = createFileRoute('/_authenticated/dashboard/tasks')({ loader: async ({ params }) => { // fetchTasks is a `createServerFn({ method: 'GET' })` server function const tasks = await fetchTasks({ data: { account: params.account } }); return { account: params.account, tasks }; }, component: TasksRoute,});function TasksRoute() { const { account, tasks } = Route.useLoaderData(); return <TasksManager accountSlug={account} initialTasks={tasks} />;}// tasks-manager.tsximport { useQuery } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';interface Props { accountSlug: string; initialTasks: Task[];}export function TasksManager({ accountSlug, initialTasks }: Props) { const supabase = useSupabase(); const { data: tasks } = useQuery({ queryKey: ['tasks', { accountSlug }], queryFn: async () => { const { data, error } = await supabase .from('tasks') .select('*') .eq('account_slug', accountSlug) .order('created_at', { ascending: false }); if (error) throw error; return data; }, // Use server data as initial value initialData: initialTasks, // Consider fresh for 30 seconds (skip immediate refetch) staleTime: 30_000, }); return ( <div> {/* tasks is initialTasks on first render, then live data */} {tasks.map((task) => ( <TaskItem key={task.id} task={task} /> ))} </div> );}Caching Configuration
Control how long data stays fresh and when to refetch:
const { data } = useQuery({ queryKey: ['tasks', accountId], queryFn: fetchTasks, // Data considered fresh for 5 minutes staleTime: 5 * 60 * 1000, // Keep unused data in cache for 30 minutes gcTime: 30 * 60 * 1000, // Refetch when window regains focus refetchOnWindowFocus: true, // Refetch every 60 seconds refetchInterval: 60_000, // Only refetch interval when tab is visible refetchIntervalInBackground: false,});Global Defaults
Set defaults for all queries in your QueryClient:
// lib/query-client.tsimport { QueryClient } from '@tanstack/react-query';export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes refetchOnWindowFocus: true, retry: 1, }, mutations: { retry: 0, }, },});Pagination
Implement paginated queries:
import { useQuery, keepPreviousData } from '@tanstack/react-query';import { useState } from 'react';import { useSupabase } from '@kit/supabase/hooks/use-supabase';const PAGE_SIZE = 10;export function PaginatedTasks({ accountId }: { accountId: string }) { const [page, setPage] = useState(0); const supabase = useSupabase(); const { data, isLoading, isPlaceholderData } = useQuery({ queryKey: ['tasks', { accountId, page }], queryFn: async () => { const from = page * PAGE_SIZE; const to = from + PAGE_SIZE - 1; const { data, error, count } = await supabase .from('tasks') .select('*', { count: 'exact' }) .eq('account_id', accountId) .order('created_at', { ascending: false }) .range(from, to); if (error) throw error; return { tasks: data, total: count ?? 0 }; }, // Keep previous data while fetching next page placeholderData: keepPreviousData, }); const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE); return ( <div> <ul className={isPlaceholderData ? 'opacity-50' : ''}> {data?.tasks.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> <div className="flex gap-2 mt-4"> <button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} > Previous </button> <span> Page {page + 1} of {totalPages} </span> <button onClick={() => setPage((p) => p + 1)} disabled={page >= totalPages - 1} > Next </button> </div> </div> );}Infinite Scroll
For infinite scrolling lists:
import { useInfiniteQuery } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';const PAGE_SIZE = 20;export function InfiniteTasksList({ accountId }: { accountId: string }) { const supabase = useSupabase(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['tasks', { accountId, infinite: true }], queryFn: async ({ pageParam }) => { const from = pageParam * PAGE_SIZE; const to = from + PAGE_SIZE - 1; const { data, error } = await supabase .from('tasks') .select('*') .eq('account_id', accountId) .order('created_at', { ascending: false }) .range(from, to); if (error) throw error; return data; }, initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { // Return undefined when no more pages return lastPage.length === PAGE_SIZE ? allPages.length : undefined; }, }); const tasks = data?.pages.flatMap((page) => page) ?? []; return ( <div> <ul> {tasks.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> )} </div> );}Real-Time with Supabase Subscriptions
Combine React Query with Supabase real-time for live updates:
import { useEffect } from 'react';import { useQuery, useQueryClient } from '@tanstack/react-query';import { useSupabase } from '@kit/supabase/hooks/use-supabase';export function LiveTasks({ accountId }: { accountId: string }) { const supabase = useSupabase(); const queryClient = useQueryClient(); const { data: tasks } = useQuery({ queryKey: ['tasks', { accountId }], queryFn: async () => { const { data, error } = await supabase .from('tasks') .select('*') .eq('account_id', accountId); if (error) throw error; return data; }, }); // Subscribe to real-time changes useEffect(() => { const channel = supabase .channel('tasks-changes') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'tasks', filter: `account_id=eq.${accountId}`, }, () => { // Invalidate and refetch on any change queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }], }); } ) .subscribe(); return () => { supabase.removeChannel(channel); }; }, [supabase, queryClient, accountId]); return ( <ul> {tasks?.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> );}Using Server Functions with React Query
Combine TanStack Start server functions with React Query mutations. Wrap the server function with useServerFn so it is callable from the client, then pass it as the mutationFn:
import { useMutation, useQueryClient } from '@tanstack/react-query';import { useServerFn } from '@tanstack/react-start';import { createTaskFunction } from '#/lib/server/tasks.functions';export function useCreateTask(accountId: string) { const queryClient = useQueryClient(); const createTask = useServerFn(createTaskFunction); return useMutation({ mutationFn: (data: { title: string }) => createTask({ data: { ...data, accountId } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }], }); }, });}// Usagefunction CreateTaskForm({ accountId }: { accountId: string }) { const createTask = useCreateTask(accountId); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createTask.mutate({ title: formData.get('title') as string, }); }} > <input name="title" required /> <button disabled={createTask.isPending}> {createTask.isPending ? 'Creating...' : 'Create'} </button> </form> );}Common Mistakes
Fetching Initial Page Data with React Query
// WRONG: blocking the first render on a client-side fetch adds a loading// flash and hurts SEOexport function TasksPage() { const { data, isLoading } = useQuery({ ... }); if (isLoading) return <Skeleton />; return <TasksList tasks={data} />;}// RIGHT: load initial data in the route loader, then hydrate React Query// via `initialData` for client-side interactivityexport const Route = createFileRoute('/_authenticated/dashboard/tasks')({ loader: () => fetchTasks(), component: TasksPage,});Unstable Query Keys
// WRONG: New object reference on every render causes infinite refetchesconst { data } = useQuery({ queryKey: ['tasks', { accountId, filters: { status: 'pending' } }], queryFn: fetchTasks,});// RIGHT: Use stable referencesconst filters = useMemo(() => ({ status: 'pending' }), []);const { data } = useQuery({ queryKey: ['tasks', { accountId, ...filters }], queryFn: fetchTasks,});// OR: Spread primitive values directlyconst { data } = useQuery({ queryKey: ['tasks', accountId, 'pending'], queryFn: fetchTasks,});Not Handling Loading States
// WRONG: Assuming data existsfunction Tasks() { const { data } = useQuery({ ... }); return <ul>{data.map(...)}</ul>; // data might be undefined}// RIGHT: Handle all statesfunction Tasks() { const { data, isLoading, error } = useQuery({ ... }); if (isLoading) return <Skeleton />; if (error) return <Error />; if (!data?.length) return <Empty />; return <ul>{data.map(...)}</ul>;}Next Steps
- Loading Data from the Database - Initial data loading with route loaders
- Writing Data to the Database - Mutations with server functions
- Supabase Clients - Browser vs server clients