From frontend-react
Data fetching patterns for React and Next.js — Server Components, Server Actions, SWR, TanStack Query, loading/error handling, and revalidation strategies.
How this skill is triggered — by the user, by Claude, or both
Slash command
/frontend-react:react-data-fetchingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Every component in the App Router is a Server Component by default. Fetch data directly with `async/await` — no `useEffect`, no client-side fetch boilerplate.
Every component in the App Router is a Server Component by default. Fetch data directly with async/await — no useEffect, no client-side fetch boilerplate.
// app/posts/page.tsx — Server Component (no "use client")
interface Post {
id: string;
title: string;
}
async function getPosts(): Promise<Post[]> {
// Direct DB access — never reaches the client bundle
const posts = await db.post.findMany({ orderBy: { createdAt: "desc" } });
return posts;
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
Next.js extends the native fetch API with automatic request deduplication and caching:
// Cached (default) — result is stored in the Data Cache across requests
const data = await fetch("https://api.example.com/data");
// No cache — always fetches fresh data (equivalent to SSR every request)
const data = await fetch("https://api.example.com/data", { cache: "no-store" });
// Revalidate on interval (ISR-style)
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 }, // seconds
});
// Tag-based revalidation
const data = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
Avoid waterfall by firing independent requests concurrently:
export default async function DashboardPage() {
// DO: fire both in parallel
const [user, analytics] = await Promise.all([
fetchUser(),
fetchAnalytics(),
]);
// DON'T: sequential waterfall
// const user = await fetchUser();
// const analytics = await fetchAnalytics();
return <Dashboard user={user} analytics={analytics} />;
}
Server Actions are async functions marked with "use server" that run on the server and can be called from the client. Primary use cases: form submissions, mutations, and cache revalidation.
// app/posts/new/page.tsx
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export default function NewPostPage() {
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.create({ data: { title, body } });
revalidatePath("/posts"); // invalidate cached route
redirect("/posts");
}
return (
<form action={createPost}>
<input name="title" required />
<textarea name="body" required />
<button type="submit">Publish</button>
</form>
);
}
// app/actions/posts.ts
"use server";
import { revalidateTag } from "next/cache";
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidateTag("posts");
}
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions/posts";
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
{state?.error && <p className="error">{state.error}</p>}
<input name="title" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? "Publishing…" : "Publish"}
</button>
</form>
);
}
| Function | Scope | Usage |
|---|---|---|
revalidatePath(path) | Single URL path | After mutation affecting a specific page |
revalidateTag(tag) | All fetches with that tag | After mutation affecting a category of data |
SWR is suited for client-side data that benefits from stale-while-revalidate semantics. Ideal when Server Components are not available or when the component must refetch on user interaction.
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading, mutate } = useSWR<User>(
`/api/users/${userId}`,
fetcher,
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 2000,
}
);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <div>{data!.name}</div>;
}
const { data, mutate } = useSWR<User>(`/api/users/${userId}`, fetcher);
// Optimistic update
async function updateName(newName: string) {
await mutate(
async (current) => {
await fetch(`/api/users/${userId}`, {
method: "PATCH",
body: JSON.stringify({ name: newName }),
});
return { ...current!, name: newName };
},
{ optimisticData: { ...data!, name: newName }, rollbackOnError: true }
);
}
// Global revalidation by key
import { mutate as globalMutate } from "swr";
globalMutate(`/api/users/${userId}`); // re-fetch everywhere
Wrap the app in SWRConfig to set defaults:
import { SWRConfig } from "swr";
const globalFetcher = (url: string) => fetch(url).then(r => r.json());
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SWRConfig value={{ fetcher: globalFetcher, revalidateOnFocus: false }}>
{children}
</SWRConfig>
);
}
TanStack Query is the preferred client-side solution for complex caching, optimistic updates, pagination, and infinite scroll.
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 minute
gcTime: 5 * 60_000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
}
"use client";
import { useQuery } from "@tanstack/react-query";
export const postsQueryOptions = {
queryKey: ["posts"] as const,
queryFn: () => fetch("/api/posts").then(r => r.json()) as Promise<Post[]>,
staleTime: 30_000,
};
export function PostList() {
const { data: posts, isLoading, isError, error } = useQuery(postsQueryOptions);
if (isLoading) return <PostsSkeleton />;
if (isError) return <ErrorAlert message={(error as Error).message} />;
return <ul>{posts!.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
fetch(`/api/posts/${id}`, { method: "DELETE" }).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
onError: (error) => {
console.error("Failed to delete post:", error);
},
});
}
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (updatedPost: Post) =>
fetch(`/api/posts/${updatedPost.id}`, {
method: "PUT",
body: JSON.stringify(updatedPost),
}).then(r => r.json()),
onMutate: async (updatedPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["posts"] });
// Snapshot current value
const previous = queryClient.getQueryData<Post[]>(["posts"]);
// Optimistically update
queryClient.setQueryData<Post[]>(["posts"], old =>
old?.map(p => p.id === updatedPost.id ? updatedPost : p) ?? []
);
return { previous };
},
onError: (_err, _vars, context) => {
// Roll back on error
if (context?.previous) {
queryClient.setQueryData(["posts"], context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
Use arrays; from most general to most specific:
["posts"] // all posts
["posts", { status: "draft" }] // filtered posts
["posts", postId] // single post
["posts", postId, "comments"] // post's comments
// app/dashboard/loading.tsx — automatically wraps segment in Suspense
export default function Loading() {
return <DashboardSkeleton />;
}
<Suspense fallback={<Skeleton count={5} />}>
<AsyncDataComponent />
</Suspense>
Prefer skeleton screens over spinners for layout-preserving loading states:
export function PostSkeleton() {
return (
<div className="animate-pulse">
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-200 rounded mb-1" />
<div className="h-4 w-5/6 bg-gray-200 rounded" />
</div>
);
}
// app/dashboard/error.tsx
"use client";
export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div role="alert">
<h2>Failed to load dashboard</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}
TanStack Query surfaces errors via isError / error; handle at the component level or via an error boundary:
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
export function SafePostList() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<PostList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
| Strategy | Mechanism | Use Case |
|---|---|---|
| On-demand (path) | revalidatePath() in Server Action | After form submission that changes a page |
| On-demand (tag) | revalidateTag() in Server Action | After mutation to a category of data |
| Time-based (ISR) | next: { revalidate: N } in fetch | Semi-static content (blog posts, product pages) |
| No cache | cache: "no-store" | Personalised or always-fresh data |
| Client interval | refetchInterval in TanStack Query | Live dashboards, polling |
| Focus revalidation | revalidateOnFocus: true in SWR | Tabs returning to active, e.g. form pages |
| Router refresh | router.refresh() | Manually trigger Server Component re-render |
| Do | Don't |
|---|---|
| Use Server Components + async/await for initial data loads | Use useEffect + fetch when a Server Component can do the job |
Fire independent fetches in parallel with Promise.all | Waterfall sequential awaits for independent resources |
Use revalidatePath/revalidateTag after mutations | Redirect without revalidating stale cache |
| Provide skeleton fallbacks for Suspense boundaries | Show raw spinners that cause layout shift |
Centralise TanStack Query queryKey factories | Inline ad-hoc key arrays in every component |
Handle errors at the appropriate boundary (error.tsx or ErrorBoundary) | Let unhandled promise rejections crash silently |
npx claudepluginhub gagandeepp/software-agent-teams --plugin frontend-reactGuides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Enforces test-driven development: write failing test first, then minimal code to pass. Use when implementing features or bugfixes.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.