Introduction to Data Fetching and API Handling

Learn about data fetching and API handling in modern web development, including REST APIs, GraphQL, state management, and best practices for building data-driven applications.

Introduction to Data Fetching and API Handling

Data fetching and API handling are fundamental aspects of modern web development. Nearly every web application needs to communicate with servers to retrieve, create, update, or delete data. Understanding how to efficiently manage these operations is crucial for building performant and maintainable applications.

What is Data Fetching?

Data fetching refers to the process of requesting and receiving data from external sources, typically through APIs (Application Programming Interfaces). In the context of web applications, this usually means:

  • Making HTTP requests to backend servers
  • Retrieving data from databases or external services
  • Sending data to servers for processing or storage
  • Managing the state of data throughout the application lifecycle

The Traditional Approach

Before modern libraries, developers typically used the native fetch API or XMLHttpRequest:

// Basic fetch example
async function getUsers() {
  try {
    const response = await fetch('https://api.example.com/users');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Error fetching users:', error);
  }
}

While this works, it requires manual handling of:

  • Loading states
  • Error handling
  • Caching
  • Request cancellation
  • Retry logic
  • Data synchronization

Why Use Data Fetching Libraries?

Modern data fetching libraries solve common challenges and provide powerful features out of the box:

1. Simplified API Calls

Libraries like Axios provide a cleaner, more intuitive API:

// Axios - cleaner syntax with automatic JSON parsing
import axios from 'axios';

const users = await axios.get('https://api.example.com/users');
// users.data is already parsed JSON

2. Automatic State Management

Libraries like TanStack Query manage loading, error, and success states automatically:

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

function Users() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json())
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return <div>{data.map(user => <div key={user.id}>{user.name}</div>)}</div>;
}

3. Intelligent Caching

Prevent unnecessary network requests by caching data:

  • Automatic caching: Store responses for reuse
  • Cache invalidation: Update stale data intelligently
  • Background refetching: Keep data fresh without blocking UI
  • Optimistic updates: Update UI before server confirms

4. Request Optimization

  • Request deduplication: Prevent duplicate requests
  • Request cancellation: Cancel pending requests when no longer needed
  • Retry logic: Automatically retry failed requests
  • Throttling/Debouncing: Control request frequency

5. Developer Experience

Modern libraries provide excellent developer experience:

  • TypeScript support: Type-safe API calls
  • DevTools: Debug and inspect requests
  • Error boundaries: Graceful error handling
  • Testing utilities: Easy to test components

Understanding REST APIs

REST (Representational State Transfer) is the most common architecture for web APIs.

HTTP Methods (CRUD Operations)

REST APIs use HTTP methods to perform operations:

MethodPurposeExample
GETRetrieve dataGET /api/users - Get all users
POSTCreate new dataPOST /api/users - Create a user
PUT/PATCHUpdate dataPUT /api/users/123 - Update user 123
DELETERemove dataDELETE /api/users/123 - Delete user 123

REST API Example

// GET - Retrieve users
const response = await fetch('https://api.example.com/users');
const users = await response.json();

// POST - Create a new user
const newUser = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'John Doe', email: 'john@example.com' })
});

// PUT - Update a user
const updated = await fetch('https://api.example.com/users/123', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Jane Doe' })
});

// DELETE - Remove a user
await fetch('https://api.example.com/users/123', {
  method: 'DELETE'
});

Understanding GraphQL

GraphQL is an alternative to REST that allows clients to request exactly the data they need.

Key Differences from REST

FeatureRESTGraphQL
EndpointsMultiple endpoints (/users, /posts)Single endpoint (/graphql)
Data FetchingFixed structureClient specifies structure
Over-fetchingCommon (get more data than needed)Minimal (request only what you need)
Under-fetchingCommon (need multiple requests)Rare (get all data in one request)

GraphQL Example

// GraphQL query - request exactly what you need
const query = `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        title
        createdAt
      }
    }
  }
`;

const response = await fetch('https://api.example.com/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query,
    variables: { id: '123' }
  })
});

const { data } = await response.json();

Server State vs Client State

Understanding the difference between server and client state is crucial for choosing the right data management solution.

Server State

Server state is data that:

  • Lives on the server (database, external API)
  • Requires asynchronous fetching
  • Can become stale (data changes over time)
  • Is shared (multiple users access the same data)
  • Needs synchronization (keep local copy in sync with server)

Examples: User profiles, product listings, blog posts, comments

Client State

Client state is data that:

  • Lives in the browser (component state, form inputs)
  • Is synchronous (immediately available)
  • Is local (specific to current user/session)
  • Doesn't need synchronization with server

Examples: Modal open/closed, form validation errors, UI theme, selected tab

Why This Matters

Traditional state management libraries (Redux, MobX) were designed for client state. Using them for server state leads to:

  • Boilerplate code for loading/error states
  • Manual cache management
  • Complex synchronization logic
  • Difficulty handling stale data

Modern data fetching libraries (TanStack Query, SWR) are specifically designed for server state and handle these concerns automatically.

Common Data Fetching Patterns

1. Fetch on Mount

Load data when component first renders:

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });

  if (isLoading) return <Skeleton />;
  return <div>{data.name}</div>;
}

2. Fetch on User Action

Load data in response to user interactions:

function SearchBar() {
  const [query, setQuery] = useState('');
  
  const { data, refetch } = useQuery({
    queryKey: ['search', query],
    queryFn: () => searchProducts(query),
    enabled: false // Don't fetch automatically
  });

  const handleSearch = () => {
    refetch(); // Manually trigger fetch
  };

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
    </XIcon>
  );
}

3. Polling (Regular Refetching)

Automatically refetch data at intervals:

function LiveDashboard() {
  const { data } = useQuery({
    queryKey: ['metrics'],
    queryFn: fetchMetrics,
    refetchInterval: 5000 // Refetch every 5 seconds
  });

  return <MetricsDisplay data={data} />;
}

4. Optimistic Updates

Update UI immediately, then sync with server:

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newData) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['user'] });
    
    // Snapshot previous value
    const previousUser = queryClient.getQueryData(['user']);
    
    // Optimistically update
    queryClient.setQueryData(['user'], newData);
    
    // Return context for rollback
    return { previousUser };
  },
  onError: (err, newData, context) => {
    // Rollback on error
    queryClient.setQueryData(['user'], context.previousUser);
  }
});

1. Axios

Type: HTTP Client Best For: Simple, clean API for making HTTP requests

Key Features:

  • Automatic JSON parsing
  • Request/response interceptors
  • Request cancellation
  • Progress tracking
  • Browser and Node.js support

Use When: You need a robust HTTP client without built-in caching or state management.

2. TanStack Query (React Query)

Type: Async State Management Best For: Managing server state with automatic caching

Key Features:

  • Automatic caching and background updates
  • Request deduplication
  • Optimistic updates
  • Infinite queries (pagination)
  • DevTools for debugging

Use When: You need sophisticated caching, automatic refetching, and server state management.

3. SWR (Stale-While-Revalidate)

Type: Data Fetching Hook Best For: Simple, lightweight data fetching with caching

Key Features:

  • Lightweight (4KB)
  • Built-in cache
  • Real-time updates
  • TypeScript support
  • SSR/SSG support

Use When: You want TanStack Query features but prefer a simpler, lighter solution.

4. Refine

Type: Framework for CRUD Applications Best For: Building admin panels, dashboards, and internal tools

Key Features:

  • Complete CRUD operations out of the box
  • Built-in authentication
  • Data provider abstraction
  • UI framework agnostic
  • Audit logs and permissions

Use When: Building data-heavy applications with standard CRUD operations.

5. Apollo Client (GraphQL)

Type: GraphQL Client Best For: Working with GraphQL APIs

Key Features:

  • Intelligent caching
  • Optimistic UI
  • Pagination
  • Real-time subscriptions
  • Local state management

Use When: Your backend uses GraphQL instead of REST.

Choosing the Right Solution

Decision Matrix

Need HTTP client only? → Axios
Need caching + state management? → TanStack Query or SWR
Building CRUD app? → Refine
Using GraphQL? → Apollo Client
Need full-stack framework? → Refine or Next.js built-in fetching

Consider These Factors

  1. Project Scale

    • Small project: Native fetch or Axios
    • Medium project: TanStack Query or SWR
    • Large CRUD app: Refine
  2. API Type

    • REST API: Axios, TanStack Query, SWR
    • GraphQL: Apollo Client, urql
  3. Caching Needs

    • No caching: Axios, native fetch
    • Automatic caching: TanStack Query, SWR
  4. Team Experience

    • Familiar with React Query: TanStack Query
    • Want simplicity: SWR
    • Need full solution: Refine

Best Practices

1. Error Handling

Always handle errors gracefully:

function UserList() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    retry: 3, // Retry failed requests
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
  });

  if (error) {
    return (
      <div>
        <p>Failed to load users</p>
        <button onClick={() => refetch()}>Try Again</button>
      </div>
    );
  }

  return <div>{/* Render users */}</div>;
}

2. Loading States

Provide feedback during data loading:

function ProductDetail() {
  const { data, isLoading } = useQuery({
    queryKey: ['product'],
    queryFn: fetchProduct
  });

  if (isLoading) {
    return <LoadingSkeleton />; // Show skeleton UI
  }

  return <ProductCard product={data} />;
}

3. Type Safety

Use TypeScript for type-safe API calls:

interface User {
  id: string;
  name: string;
  email: string;
}

const { data } = useQuery<User>({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
});

// data is typed as User | undefined

4. Request Cancellation

Cancel requests when components unmount:

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error('Fetch error:', err);
      }
    });

  return () => controller.abort(); // Cancel on unmount
}, []);

5. Environment Variables

Store API URLs and keys securely:

# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com
API_SECRET_KEY=your-secret-key
// Use in your app
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Next.js Specific Considerations

Server Components (App Router)

With Next.js 13+ App Router, you can fetch data directly in Server Components:

// app/users/page.tsx
async function UsersPage() {
  // This runs on the server
  const users = await fetch('https://api.example.com/users').then(r => r.json());

  return (
    <div>
      {users.map(user => <div key={user.id}>{user.name}</div>)}
    </div>
  );
}

API Routes

Create API endpoints in Next.js:

// app/api/users/route.ts
export async function GET() {
  const users = await db.users.findMany();
  return Response.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.users.create({ data: body });
  return Response.json(user);
}

Server Actions

Use Server Actions for mutations:

// app/actions.ts
'use server'

export async function createUser(formData: FormData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  await db.users.create({
    data: { name, email }
  });
  
  revalidatePath('/users');
}

Performance Considerations

1. Minimize Request Waterfall

Avoid sequential requests when possible:

// ❌ Bad - Sequential requests (slow)
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts.map(p => p.id));

// ✅ Good - Parallel requests (fast)
const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
]);

2. Implement Pagination

Don't load all data at once:

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['products'],
  queryFn: ({ pageParam = 0 }) => fetchProducts(pageParam),
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
});

3. Prefetch Data

Load data before it's needed:

// Prefetch on hover
const queryClient = useQueryClient();

const handleMouseEnter = () => {
  queryClient.prefetchQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId)
  });
};

return <Link onMouseEnter={handleMouseEnter}>View Product</Link>;

4. Use Suspense (React 18+)

Leverage React Suspense for better loading states:

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <UserProfile />
    </Suspense>
  );
}

Security Best Practices

1. Protect API Keys

Never expose secret keys in client-side code:

// ❌ Bad - Secret exposed
const data = await fetch('https://api.example.com/data', {
  headers: { 'API-Key': 'secret_12345' }
});

// ✅ Good - Use API route as proxy
const data = await fetch('/api/data'); // API route adds the key

// app/api/data/route.ts
export async function GET() {
  const data = await fetch('https://api.example.com/data', {
    headers: { 'API-Key': process.env.API_SECRET_KEY }
  });
  return data;
}

2. Validate and Sanitize

Always validate data from APIs:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email()
});

async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return UserSchema.parse(data); // Throws if invalid
}

3. Implement Rate Limiting

Protect your API from abuse:

// Simple client-side rate limiting
import { throttle } from 'lodash';

const searchThrottled = throttle((query: string) => {
  searchAPI(query);
}, 1000); // Max once per second

Summary

Data fetching and API handling are essential skills for modern web development. The ecosystem offers various solutions tailored to different needs:

  • Axios: For clean, simple HTTP requests
  • TanStack Query: For sophisticated caching and state management
  • SWR: For lightweight data fetching with caching
  • Refine: For complete CRUD application frameworks
  • Apollo Client: For GraphQL APIs

Choose based on your project requirements, team expertise, and the specific challenges you need to solve. In the following lessons, we'll explore each of these libraries in detail with practical examples.