frontend-react-data-fetching

star 4

Fetching data from APIs

tools-only By tools-only schedule Updated 3/1/2026

name: frontend-react-data-fetching description: Fetching data from APIs

React Data Fetching

Scope: SWR, React Query, Server Actions, caching strategies, optimistic updates Lines: ~310 Last Updated: 2025-10-18

When to Use This Skill

Activate this skill when:

  • Fetching data from APIs
  • Implementing caching strategies
  • Handling loading and error states
  • Optimistic UI updates
  • Infinite scrolling/pagination
  • Real-time data synchronization
  • Choosing between SWR and React Query

Core Concepts

Client vs Server Data

Client State - UI state, form inputs, modals

const [isOpen, setIsOpen] = useState(false);

Server State - Data from API, database

const { data: posts } = useSWR('/api/posts');

Key differences:

  • Server state is async, remote, shared
  • Server state can be stale
  • Server state needs caching, revalidation
  • Server state is owned by server

SWR (Stale-While-Revalidate)

Basic Usage

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(res => res.json());

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Failed to load</div>;

  return <div>Hello {data.name}!</div>;
}

Global Configuration

// app/layout.tsx or _app.tsx
import { SWRConfig } from 'swr';

const fetcher = (url: string) => fetch(url).then(res => {
  if (!res.ok) throw new Error('API error');
  return res.json();
});

export default function App({ children }) {
  return (
    <SWRConfig value={{
      fetcher,
      revalidateOnFocus: false,
      revalidateOnReconnect: true,
      dedupingInterval: 2000,
    }}>
      {children}
    </SWRConfig>
  );
}

Mutations

function UpdateProfile() {
  const { data: user, mutate } = useSWR('/api/user', fetcher);

  const updateName = async (newName: string) => {
    // Optimistic update
    mutate({ ...user, name: newName }, false);

    // API call
    await fetch('/api/user', {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
    });

    // Revalidate
    mutate();
  };

  return (
    <input
      value={user?.name}
      onChange={(e) => updateName(e.target.value)}
    />
  );
}

Dependent Queries

function UserPosts({ userId }: { userId: string }) {
  // First fetch user
  const { data: user } = useSWR(`/api/users/${userId}`, fetcher);

  // Then fetch user's posts (only when user is loaded)
  const { data: posts } = useSWR(
    user ? `/api/users/${user.id}/posts` : null,
    fetcher
  );

  if (!user) return <div>Loading user...</div>;
  if (!posts) return <div>Loading posts...</div>;

  return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}

Pagination

function PostList() {
  const [page, setPage] = useState(1);

  const { data, isLoading } = useSWR(
    `/api/posts?page=${page}&limit=10`,
    fetcher
  );

  return (
    <div>
      {data?.posts.map(post => <div key={post.id}>{post.title}</div>)}

      <button onClick={() => setPage(page - 1)} disabled={page === 1}>
        Previous
      </button>
      <button onClick={() => setPage(page + 1)}>
        Next
      </button>
    </div>
  );
}

Infinite Scroll

import useSWRInfinite from 'swr/infinite';

function InfinitePostList() {
  const getKey = (pageIndex: number, previousPageData: any) => {
    // Reached the end
    if (previousPageData && !previousPageData.posts.length) return null;

    // First page
    return `/api/posts?page=${pageIndex + 1}&limit=10`;
  };

  const { data, size, setSize, isLoading } = useSWRInfinite(getKey, fetcher);

  const posts = data ? data.flatMap(page => page.posts) : [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.posts.length === 0;
  const isReachingEnd = isEmpty || (data && data[data.length - 1]?.posts.length < 10);

  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}

      {!isReachingEnd && (
        <button onClick={() => setSize(size + 1)} disabled={isLoadingMore}>
          {isLoadingMore ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

React Query (TanStack Query)

Basic Usage

import { useQuery } from '@tanstack/react-query';

function Posts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('/api/posts');
      if (!res.ok) throw new Error('Failed to fetch');
      return res.json();
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data.map((post: Post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

Query Client Setup

// 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 * 1000, // 1 minute
        refetchOnWindowFocus: false,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Mutations

import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreatePost() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newPost: { title: string; content: string }) => {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      });
      if (!res.ok) throw new Error('Failed to create');
      return res.json();
    },
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ title: 'New Post', content: 'Content' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

Optimistic Updates

function UpdatePost({ post }: { post: Post }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (updatedPost: Post) => {
      const res = await fetch(`/api/posts/${post.id}`, {
        method: 'PUT',
        body: JSON.stringify(updatedPost),
      });
      return res.json();
    },

    // Optimistic update
    onMutate: async (updatedPost) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['posts', post.id] });

      // Snapshot previous value
      const previousPost = queryClient.getQueryData(['posts', post.id]);

      // Optimistically update cache
      queryClient.setQueryData(['posts', post.id], updatedPost);

      // Return context with snapshot
      return { previousPost };
    },

    // Rollback on error
    onError: (err, updatedPost, context) => {
      queryClient.setQueryData(['posts', post.id], context?.previousPost);
    },

    // Always refetch after error or success
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts', post.id] });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ ...post, title: 'Updated' })}>
      Update
    </button>
  );
}

Pagination

function PaginatedPosts() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ['posts', page],
    queryFn: async () => {
      const res = await fetch(`/api/posts?page=${page}&limit=10`);
      return res.json();
    },
    placeholderData: (previousData) => previousData, // Keep previous data while fetching
  });

  return (
    <div>
      {data?.posts.map((post: Post) => (
        <div key={post.id}>{post.title}</div>
      ))}

      <button
        onClick={() => setPage(page - 1)}
        disabled={page === 1}
      >
        Previous
      </button>
      <button
        onClick={() => setPage(page + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next
      </button>
    </div>
  );
}

Infinite Queries

import { useInfiniteQuery } from '@tanstack/react-query';

function InfinitePosts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 1 }) => {
      const res = await fetch(`/api/posts?page=${pageParam}&limit=10`);
      return res.json();
    },
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
    initialPageParam: 1,
  });

  const posts = data?.pages.flatMap(page => page.posts) ?? [];

  return (
    <div>
      {posts.map((post: Post) => (
        <div key={post.id}>{post.title}</div>
      ))}

      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Next.js Server Actions

Basic Server Action

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await prisma.post.create({
    data: { title, content }
  });

  revalidatePath('/posts');
}

// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  );
}

Client-Side with useFormStatus

// app/components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
import { SubmitButton } from '@/app/components/SubmitButton';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <SubmitButton />
    </form>
  );
}

useFormState for Validation

// app/actions.ts
'use server';

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // Validation
  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' };
  }

  await prisma.post.create({ data: { title, content } });

  return { success: true };
}

// app/components/PostForm.tsx
'use client';

import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions';

export function PostForm() {
  const [state, formAction] = useFormState(createPost, null);

  return (
    <form action={formAction}>
      <input name="title" required />
      {state?.error && <p className="error">{state.error}</p>}
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  );
}

Caching Strategies

Stale-While-Revalidate (SWR Pattern)

// Return stale data immediately, revalidate in background
const { data } = useSWR('/api/posts', fetcher, {
  revalidateOnFocus: true,    // Revalidate when window regains focus
  revalidateOnReconnect: true, // Revalidate when network reconnects
  dedupingInterval: 2000,      // Dedupe requests within 2 seconds
});

Cache and Network (React Query)

const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 5 * 60 * 1000,    // 5 minutes before stale
  cacheTime: 10 * 60 * 1000,   // 10 minutes in cache
  refetchOnWindowFocus: true,
  refetchOnReconnect: true,
});

Time-Based Revalidation (Next.js)

// app/posts/page.tsx
export const revalidate = 60; // Revalidate every 60 seconds

async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 }
  });
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}

On-Demand Revalidation (Next.js)

// app/actions.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(data: any) {
  await prisma.post.create({ data });

  // Revalidate specific paths
  revalidatePath('/posts');
  revalidatePath('/');

  // Or revalidate by tag
  revalidateTag('posts');
}

// Fetch with tag
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }
  });
  return res.json();
}

SWR vs React Query

Comparison

Feature SWR React Query
Bundle Size 4.3kb 12.9kb
Complexity Simple More features
DevTools No Yes
Mutations Manual Built-in
TypeScript Good Excellent
Best For Simple apps, Next.js Complex apps, large teams

When to Use SWR

✅ Next.js projects (official Vercel library)
✅ Simple data fetching needs
✅ Small bundle size priority
✅ Straightforward caching
✅ Quick setup

When to Use React Query

✅ Complex mutations
✅ Optimistic updates
✅ DevTools needed
✅ Pagination/infinite scroll
✅ Large apps with many queries
✅ Advanced caching strategies

Quick Reference

SWR Basic Pattern

const { data, error, isLoading, mutate } = useSWR(key, fetcher, options);

// Mutate
mutate(newData, false); // Optimistic
mutate();               // Revalidate

React Query Basic Pattern

const { data, error, isLoading } = useQuery({
  queryKey: ['key'],
  queryFn: fetchFn,
});

const mutation = useMutation({
  mutationFn: createFn,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['key'] }),
});

Server Actions Pattern

'use server';
export async function action(formData: FormData) {
  // Mutation
  revalidatePath('/path');
}

// Client
<form action={action}>...</form>

Common Anti-Patterns

Fetching in useEffect: Use SWR/React Query instead ✅ Dedicated data fetching library

Global state for server data: Server state !== client state ✅ Keep server data in SWR/React Query cache

No loading/error states: Poor UX ✅ Always handle loading, error, empty states

Not caching: Unnecessary refetches ✅ Use staleTime, cacheTime appropriately


Related Skills

  • react-state-management.md - Client state (Zustand, Context)
  • nextjs-app-router.md - Server Components, Server Actions
  • frontend-performance.md - Request deduplication, prefetching
  • react-form-handling.md - Form validation, submission

Last Updated: 2025-10-18 Format Version: 1.0 (Atomic)

Install via CLI
npx skills add https://github.com/tools-only/X-Skills --skill frontend-react-data-fetching
Repository Details
star Stars 4
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator