API Integration
Almost every React app shows data from a server. The basic pattern — fetch in an effect, keep data, loading and error in state — is worth knowing by heart.
On this page · 6 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Fetch data in useEffect
- Track data, loading and error state
- Cancel stale requests with AbortController
- Refetch when an input such as a search term changes
Explanation#
Rendering must be pure, so network requests go in useEffect (or an event handler for user actions like “Save”).
Keep three pieces of state: data, loading and error. Render each case.
fetch only rejects on network failure. Check response.ok and throw for 4xx/5xx responses yourself.
When the effect depends on something (a userId, a search term), list it in the dependency array. Cancel the previous request in the cleanup with an AbortController, so a slow old response cannot overwrite a newer one.
For bigger apps, libraries such as TanStack Query or SWR add caching, retries and background refresh. The manual pattern teaches you what they do.
Syntax#
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort();
}, [url]);
Code example: Posts for the selected user#
import { useEffect, useState } from 'react';
export default function UserPosts() {
const [userId, setUserId] = useState(1);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setPosts(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort(); // cancel if userId changes or we unmount
}, [userId]);
return (
<>
<select value={userId} onChange={e => setUserId(Number(e.target.value))} aria-label="User">
{[1, 2, 3].map(id => <option key={id} value={id}>User {id}</option>)}
</select>
{loading && <p>Loading posts…</p>}
{error && <p role="alert">Could not load posts: {error}</p>}
{!loading && !error && (
<ul>{posts.slice(0, 5).map(p => <li key={p.id}>{p.title}</li>)}</ul>
)}
</>
);
}
The component shows “Loading posts…”, then the first five post titles of user 1. Choosing User 2 cancels any request still in flight, shows “Loading posts…” again and then user 2’s titles. If the network fails, “Could not load posts: …” appears.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- The effect runs after the first render and whenever userId changes.
- Loading and error are reset at the start of each request.
- res.ok is checked so HTTP errors become real errors.
- Cleanup aborts the previous request; AbortError is ignored because it is expected.
- The JSX handles all three states explicitly.
Real-world use case#
A customer dashboard loads orders for the selected date range. Rapidly changing the range never shows results for an older range, because older requests are aborted.
Common mistakes#
Calling fetch during render triggers a request on every render — and setting state there loops forever.
fetch resolves for 404 and 500. Without a check, you try to render an error page as data.
Without abort or an “ignore” flag, a slow response for an old query can arrive last and overwrite the new results.
Best practices#
- Always track loading and error
- Abort stale requests in cleanup
- Put fetching logic in a custom hook or data library
- Keep API base URLs in environment variables
Remember this#
Fetch in useEffect (or event handlers), never during render.
data + loading + error = three states to render.
Cleanup → controller.abort().
Practice exercise#
Your task: Write a component that loads https://jsonplaceholder.typicode.com/users once and lists the names, with loading and error states.
Need a hint?
Show solution
import { useEffect, useState } from 'react';
export default function Users() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
const controller = new AbortController();
fetch('https://jsonplaceholder.typicode.com/users', { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => { setUsers(data); setStatus('done'); })
.catch(err => { if (err.name !== 'AbortError') setStatus('error'); });
return () => controller.abort();
}, []);
if (status === 'loading') return <p>Loading…</p>;
if (status === 'error') return <p role="alert">Failed to load users.</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Mini quiz#
Does fetch reject its promise for a 404 response?
Want more? Practise all React MCQs · React interview questions
My notes#
Saved in this browser.