Skip to content

API Integration

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).

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.js
import 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 calls
api.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;

TanStack Query (formerly React Query) is our recommended library for data fetching and state management. It dramatically simplifies managing server state in React applications.

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:

  1. Race Conditions: If the user ID changes rapidly, you’ll get responses in an unpredictable order
  2. No Caching: The same data is fetched repeatedly even if nothing changed
  3. No Background Updates: Data doesn’t automatically refresh
  4. Complex State Management: Need to manually track loading, error, and data states
  5. No Retry Logic: Failed requests aren’t automatically retried
  6. Duplicate Request Logic: Data-fetching code is duplicated across components
// src/components/UserProfile.jsx
import { 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>
);
}

Wrap your application with a QueryClientProvider to enable TanStack Query:

// src/App.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create a client
const 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>
);
}

Use useMutation to update, create, or delete data:

// src/components/UpdateUserForm.jsx
import { 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 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.jsx
import { 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
}

Handle infinite scroll with TanStack Query:

// src/components/PostFeed.jsx
import { 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>
);
}

To maintain clean separation of concerns, organize API calls into service modules within a dedicated /services directory.

  • 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
// src/services/userService.js
import 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`);
};
  1. Reusability: Services can be used across multiple components
  2. Maintainability: Easier to update API endpoints or add features
  3. Testing: Makes unit testing simpler with clear boundaries
  4. Consistency: Standardizes how API calls are made throughout the application
  1. 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 typing
    performSearch(value);
    }, 300);
    return (
    <input
    type="text"
    value={query}
    onChange={(e) => {
    setQuery(e.target.value);
    debouncedSearch(e.target.value);
    }}
    />
    );
    }
  2. Use Query Keys Strategically: Organize query keys to enable selective invalidation

    // User list query
    useQuery({ queryKey: ['users', { status: 'active' }], ... });
    // User details query
    useQuery({ queryKey: ['user', userId], ... });
    // Invalidate all user queries when something changes
    queryClient.invalidateQueries({ queryKey: ['users'] });
    // Only invalidate active users
    queryClient.invalidateQueries({
    queryKey: ['users', { status: 'active' }]
    });
  3. 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...' }],
    });
  1. Separate Data Fetching from UI: Create custom hooks for data fetching logic to keep components focused on rendering
    // Bad: Mixing data fetching and UI
    function 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 concerns
    function 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>;
    }
  1. 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 components
    function UserList() {
    const { data, error, refetch } = useUsers();
    if (error) return <QueryErrorMessage error={error} refetch={refetch} />;
    // Rest of component
    }