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 Server Components. Tested with TanStack Query v5 (uses gcTime instead of cacheTime).
When to use React Query and Server Components?
Use React Query for real-time updates, optimistic mutations, pagination, and shared client-side state.
Use Server Components for initial page loads and SEO content. Combine both: load data server-side, 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 Server Components 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:
'use client';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:
'use client';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:
'use client';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 Server Components
Load initial data in Server Components, then hydrate React Query for client-side updates:
// app/tasks/page.tsx (Server Component)import { getSupabaseServerClient } from '@kit/supabase/server-client';import { TasksManager } from './tasks-manager';export default async function TasksPage({ params,}: { params: Promise<{ account: string }>;}) { const { account } = await params; const supabase = getSupabaseServerClient(); const { data: tasks } = await supabase .from('tasks') .select('*') .eq('account_slug', account) .order('created_at', { ascending: false }); return ( <TasksManager accountSlug={account} initialTasks={tasks ?? []} /> );}// tasks-manager.tsx (Client Component)'use client';import { 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:
'use client';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:
'use client';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:
'use client';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 Actions with React Query
Combine Server Actions with React Query mutations:
'use client';import { useMutation, useQueryClient } from '@tanstack/react-query';import { createTask } from './actions'; // Server Actionexport function useCreateTask(accountId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: createTask, // Server Action as mutation function 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, accountId, }); }} > <input name="title" required /> <button disabled={createTask.isPending}> {createTask.isPending ? 'Creating...' : 'Create'} </button> </form> );}Common Mistakes
Forgetting 'use client'
// WRONG: React Query hooks require client componentsexport function Tasks() { const { data } = useQuery({ ... }); // Error: hooks can't run on server}// RIGHT: Mark as client component'use client';export function Tasks() { const { data } = useQuery({ ... });}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
- Server Components - Initial data loading
- Server Actions - Mutations with Server Actions
- Supabase Clients - Browser vs server clients