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
714 lines
17 KiB
Plaintext
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
|