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:
| Method | Purpose | Example |
|---|---|---|
| GET | Retrieve data | GET /api/users - Get all users |
| POST | Create new data | POST /api/users - Create a user |
| PUT/PATCH | Update data | PUT /api/users/123 - Update user 123 |
| DELETE | Remove data | DELETE /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
| Feature | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple endpoints (/users, /posts) | Single endpoint (/graphql) |
| Data Fetching | Fixed structure | Client specifies structure |
| Over-fetching | Common (get more data than needed) | Minimal (request only what you need) |
| Under-fetching | Common (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);
}
});
Popular Data Fetching Libraries
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
-
Project Scale
- Small project: Native fetch or Axios
- Medium project: TanStack Query or SWR
- Large CRUD app: Refine
-
API Type
- REST API: Axios, TanStack Query, SWR
- GraphQL: Apollo Client, urql
-
Caching Needs
- No caching: Axios, native fetch
- Automatic caching: TanStack Query, SWR
-
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.