API Integration
API Integration & Data Fetching
Section titled “API Integration & Data Fetching”Modern React applications typically need to communicate with backend services. This guide covers our recommended patterns for API integration, data fetching, and authentication with a focus on TanStack Query (formerly React Query).
Configured Axios Instance
Section titled “Configured Axios Instance”Axios is our preferred HTTP client for API calls due to its simplicity, reliability, and feature-rich API. Create a centralized Axios instance with interceptors that can be reused throughout your application:
// src/utils/axios.jsimport axios from 'axios';
const baseURL = process.env.REACT_APP_API_URL || 'https://api.example.com';
const api = axios.create({ baseURL, timeout: 10000, headers: { 'Content-Type': 'application/json', }, withCredentials: true,});
// Request interceptor for API callsapi.interceptors.request.use( (config) => { const token = localStorage.getItem('accessToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error));
export default api;
Data Fetching with TanStack Query
Section titled “Data Fetching with TanStack Query”TanStack Query (formerly React Query) is our recommended library for data fetching and state management. It dramatically simplifies managing server state in React applications.
Why TanStack Query Over useEffect
Section titled “Why TanStack Query Over useEffect”function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { const fetchUser = async () => { try { setLoading(true); setError(null); const response = await userService.getUserProfile(userId); setUser(response.data); } catch (err) { setError(err.message || 'Failed to fetch user'); } finally { setLoading(false); } };
fetchUser(); }, [userId]);
// Render logic for loading, error, and data states}
The above approach has several critical issues:
- Race Conditions: If the user ID changes rapidly, you’ll get responses in an unpredictable order
- No Caching: The same data is fetched repeatedly even if nothing changed
- No Background Updates: Data doesn’t automatically refresh
- Complex State Management: Need to manually track loading, error, and data states
- No Retry Logic: Failed requests aren’t automatically retried
- Duplicate Request Logic: Data-fetching code is duplicated across components
function UserProfile({ userId }) { const { data: user, error, isLoading, isError, isFetching } = useQuery({ queryKey: ['user', userId], queryFn: () => userService.getUserProfile(userId), select: (response) => response.data, staleTime: 5 * 60 * 1000, // Data considered fresh for 5 minutes cacheTime: 10 * 60 * 1000, // Cache data for 10 minutes refetchOnWindowFocus: true, // Auto-refetch when window is focused retry: 3, // Retry failed requests 3 times onError: (error) => { console.error('Failed to fetch user:', error); } });
// Now we can easily tell if we're loading for the first time // or if we're refetching in the background if (isLoading) return <div>Loading...</div>; if (isError) return <div>Error: {error.message}</div>;
return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> {isFetching && <div>Refreshing...</div>} </div> );}
TanStack Query solves all the issues mentioned:
- Handles Race Conditions: Automatically manages request cancellation and data synchronization
- Built-in Caching: Intelligently caches results to minimize network requests
- Background Updates: Automatically refreshes data when needed
- Simplified State Management: Provides clear states like
isLoading
,isError
, andisFetching
- Automatic Retries: Configurable retry behavior for failed requests
- Centralized Request Logic: Request logic is defined once and reused everywhere
- Deduplicated Requests: Prevents duplicate requests for the same data
It also offers additional powerful features:
- Parallel Queries: Easily manage multiple concurrent requests
- Dependent Queries: Chain queries so one depends on another’s result
- Pagination & Infinite Scroll: Built-in support with minimal code
- Optimistic Updates: Update UI before server confirms changes
- Query Invalidation: Intelligently refresh queries when data changes
- Prefetching: Load data before it’s needed
Basic Query Example
Section titled “Basic Query Example”// src/components/UserProfile.jsximport { useQuery } from '@tanstack/react-query';import { getUserProfile } from '../services/userService';
function UserProfile({ userId }) { const { data: user, isLoading, isError, error } = useQuery({ queryKey: ['user', userId], queryFn: () => getUserProfile(userId).then(res => res.data) });
if (isLoading) return <div>Loading user details...</div>; if (isError) return <div>Error: {error.message}</div>;
return ( <div> <h1>{user.name}</h1> <p>Email: {user.email}</p> {/* Other user details */} </div> );}
Setting Up TanStack Query
Section titled “Setting Up TanStack Query”Wrap your application with a QueryClientProvider
to enable TanStack Query:
// src/App.jsximport { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create a clientconst queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes, set this up according to your application data and logic refetchOnWindowFocus: true, }, },});
function App() { return ( <QueryClientProvider client={queryClient}> {/* Your app components */} <ReactQueryDevtools initialIsOpen={false} /> {/* Development tool */} </QueryClientProvider> );}
Advanced TanStack Query Features
Section titled “Advanced TanStack Query Features”Data Mutations
Section titled “Data Mutations”Use useMutation
to update, create, or delete data:
// src/components/UpdateUserForm.jsximport { useMutation, useQueryClient } from '@tanstack/react-query';import { updateUserProfile } from '../services/userService';
function UpdateUserForm({ userId, initialData }) { const [formData, setFormData] = useState(initialData); const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: (data) => updateUserProfile(userId, data), onSuccess: (data) => { // Invalidate and refetch the user query queryClient.invalidateQueries({ queryKey: ['user', userId] }); toast.success('Profile updated successfully!'); }, onError: (error) => { toast.error(`Update failed: ${error.message}`); } });
const handleSubmit = (e) => { e.preventDefault(); mutation.mutate(formData); };
// Form rendering logic}
Optimistic Updates
Section titled “Optimistic Updates”Optimistic updates provide a better user experience by immediately updating the UI as if the server request has already succeeded, then reconciling with the actual server response when it arrives. This approach creates a more responsive interface, especially when dealing with high-latency connections. One common example is toggling a todo item’s status in a todo list, or liking a post on Instagram or Facebook, it always happens instantly, while it executes the appropriate request in the background.
The key principles of optimistic updates are:
- Update the UI immediately on user action
- Send the API request in the background
- Roll back changes if the request fails
- Synchronize with the server response when it succeeds
Here’s a comprehensive example with a todo list:
// src/components/TodoList.jsximport { useMutation, useQueryClient } from '@tanstack/react-query';import { toggleTodoStatus } from '../services/todoService';
function TodoList() { const queryClient = useQueryClient(); const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: () => getTodos().then(res => res.data) });
const mutation = useMutation({ mutationFn: toggleTodoStatus, // Update the cache optimistically onMutate: async (updatedTodo) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update the cache queryClient.setQueryData(['todos'], old => old.map(todo => todo.id === updatedTodo.id ? updatedTodo : todo ) );
// Return the snapshot for rollback return { previousTodos }; }, // If mutation fails, roll back onError: (err, _, context) => { queryClient.setQueryData(['todos'], context.previousTodos); toast.error('Failed to update todo'); }, // Always refetch to ensure consistency onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); } });
const handleToggle = (todo) => { mutation.mutate({...todo, completed: !todo.completed}); };
// Rendering logic}
Infinite Queries for Pagination
Section titled “Infinite Queries for Pagination”Handle infinite scroll with TanStack Query:
// src/components/PostFeed.jsximport { useInfiniteQuery } from '@tanstack/react-query';import { getPosts } from '../services/postService';import IntersectionObserver from '../components/IntersectionObserver';
function PostFeed() { const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, status, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 1 }) => getPosts({ page: pageParam, limit: 10 }).then(res => res.data), getNextPageParam: (lastPage, pages) => { return lastPage.hasMore ? pages.length + 1 : undefined; }, });
return ( <div> <h1>Post Feed</h1>
{status === 'loading' ? ( <p>Loading posts...</p> ) : status === 'error' ? ( <p>Error: {error.message}</p> ) : ( <> {data.pages.map((group, i) => ( <React.Fragment key={i}> {group.posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </React.Fragment> ))}
<div ref={loadMoreRef}> {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load more' : 'No more posts'} </div>
{/* Intersection Observer to trigger infinite loading */} {hasNextPage && ( <IntersectionObserver onIntersect={() => fetchNextPage()} /> )} </> )} </div> );}
Structuring API Logic in Services
Section titled “Structuring API Logic in Services”To maintain clean separation of concerns, organize API calls into service modules within a dedicated /services
directory.
Recommended Services Structure
Section titled “Recommended Services Structure”Directorysrc/
Directoryutils/
- axios.js // Axios instance and interceptors
Directoryservices/
- userService.js // User-related API calls
- authService.js // Authentication API calls
- postService.js // Post-related API calls
Example Service Implementation
Section titled “Example Service Implementation”// src/services/userService.jsimport api from '../utils/axios';
export const getUserProfile = async (userId) => { return await api.get(`/users/${userId}`);};
export const updateUserProfile = async (userId, userData) => { return await api.put(`/users/${userId}`, userData);};
export const getUserPosts = async (userId) => { return await api.get(`/users/${userId}/posts`);};
Benefits of the Service Pattern
Section titled “Benefits of the Service Pattern”- Reusability: Services can be used across multiple components
- Maintainability: Easier to update API endpoints or add features
- Testing: Makes unit testing simpler with clear boundaries
- Consistency: Standardizes how API calls are made throughout the application
Best Practices for React API Integration
Section titled “Best Practices for React API Integration”Performance Best Practices
Section titled “Performance Best Practices”-
Debounce rapid API calls for search inputs and other frequent updates
import { useDebouncedCallback } from 'use-debounce';function SearchComponent() {const [query, setQuery] = useState('');const debouncedSearch = useDebouncedCallback((value) => {// This will only execute 300ms after the user stops typingperformSearch(value);}, 300);return (<inputtype="text"value={query}onChange={(e) => {setQuery(e.target.value);debouncedSearch(e.target.value);}}/>);} -
Use Query Keys Strategically: Organize query keys to enable selective invalidation
// User list queryuseQuery({ queryKey: ['users', { status: 'active' }], ... });// User details queryuseQuery({ queryKey: ['user', userId], ... });// Invalidate all user queries when something changesqueryClient.invalidateQueries({ queryKey: ['users'] });// Only invalidate active usersqueryClient.invalidateQueries({queryKey: ['users', { status: 'active' }]}); -
Use Placeholder Data: Improve perceived performance with immediate UI updates
useQuery({queryKey: ['users'],queryFn: getUsers,placeholderData: previousData => previousData || [],// Or provide static placeholder data// placeholderData: [{ id: 1, name: 'Loading...' }],});
Component Design Best Practices
Section titled “Component Design Best Practices”- Separate Data Fetching from UI: Create custom hooks for data fetching logic to keep components focused on rendering
// Bad: Mixing data fetching and UIfunction UserList() {const [users, setUsers] = useState([]);useEffect(() => {fetchUsers().then(data => setUsers(data));}, []);return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>;}// Good: Separating concernsfunction useUsers() {return useQuery({ queryKey: ['users'], queryFn: fetchUsers });}function UserList() {const { data: users, isLoading } = useUsers();if (isLoading) return <div>Loading...</div>;return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>;}
Error Handling Best Practices
Section titled “Error Handling Best Practices”- Create Consistent Error UIs: Use a standard pattern for error states
function QueryErrorMessage({ error, refetch }) {return (<div className="error-container"><h3>Error Loading Data</h3><p>{error.message}</p><button onClick={() => refetch()}>Retry</button></div>);}// In componentsfunction UserList() {const { data, error, refetch } = useUsers();if (error) return <QueryErrorMessage error={error} refetch={refetch} />;// Rest of component}
Further Reading
Section titled “Further Reading”- TanStack Query Documentation - Comprehensive guide to React Query features
- React Suspense for Data Fetching - Official documentation on React Suspense
- Inside React Query Blog Series - An excellent deep dive into React Query patterns
- TanStack Query Crash Course - Hands-on tutorial covering React Query fundamentals and implementation