Files
myeasycms-v2/docs/data-fetching/react-query.mdoc
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

714 lines
17 KiB
Plaintext

---
status: "published"
title: "Client-Side Data Fetching with React Query"
label: "React Query"
description: "Use React Query (TanStack Query) for client-side data fetching in MakerKit. Covers queries, mutations, caching, optimistic updates, and combining with Server Components."
order: 5
---
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:
```tsx
'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:
```tsx
// Specific task
queryKey: ['tasks', taskId]
// All tasks for an account
queryKey: ['tasks', { accountId }]
// All tasks for an account with filters
queryKey: ['tasks', { accountId, status: 'pending', page: 1 }]
// Invalidate all task queries
queryClient.invalidateQueries({ queryKey: ['tasks'] });
// Invalidate tasks for specific account
queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] });
```
### Query Key Factory
For larger apps, create a query key factory:
```tsx
// lib/query-keys.ts
export 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,
},
};
// Usage
const { data } = useQuery({
queryKey: queryKeys.tasks.list(accountId),
queryFn: () => fetchTasks(accountId),
});
// Invalidate all tasks
queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all });
```
## Mutations
Use `useMutation` for create, update, and delete operations:
```tsx
'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:
```tsx
'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:
```tsx
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:
```tsx
// 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 ?? []}
/>
);
}
```
```tsx
// 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:
```tsx
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:
```tsx
// lib/query-client.ts
import { 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:
```tsx
'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:
```tsx
'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:
```tsx
'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:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTask } from './actions'; // Server Action
export function useCreateTask(accountId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTask, // Server Action as mutation function
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
},
});
}
// Usage
function 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'
```tsx
// WRONG: React Query hooks require client components
export 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
```tsx
// WRONG: New object reference on every render causes infinite refetches
const { data } = useQuery({
queryKey: ['tasks', { accountId, filters: { status: 'pending' } }],
queryFn: fetchTasks,
});
// RIGHT: Use stable references
const filters = useMemo(() => ({ status: 'pending' }), []);
const { data } = useQuery({
queryKey: ['tasks', { accountId, ...filters }],
queryFn: fetchTasks,
});
// OR: Spread primitive values directly
const { data } = useQuery({
queryKey: ['tasks', accountId, 'pending'],
queryFn: fetchTasks,
});
```
### Not Handling Loading States
```tsx
// WRONG: Assuming data exists
function Tasks() {
const { data } = useQuery({ ... });
return <ul>{data.map(...)}</ul>; // data might be undefined
}
// RIGHT: Handle all states
function 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](server-components) - Initial data loading
- [Server Actions](server-actions) - Mutations with Server Actions
- [Supabase Clients](supabase-clients) - Browser vs server clients