React · Routing & Data

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.

Intermediate 6 min Lesson 22 of 42Examples target React 19 with Vite.
Before you start:useEffect
0/42 in React
On this page · 6 min · Intermediate

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#

JSX Illustrative
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#

JSXUserPosts.jsx Runnable
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>
      )}
    </>
  );
}
Expected output
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#

  1. The effect runs after the first render and whenever userId changes.
  2. Loading and error are reset at the start of each request.
  3. res.ok is checked so HTTP errors become real errors.
  4. Cleanup aborts the previous request; AbortError is ignored because it is expected.
  5. 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#

fetch in the component body

Calling fetch during render triggers a request on every render — and setting state there loops forever.

Not checking response.ok

fetch resolves for 404 and 500. Without a check, you try to render an error page as data.

Race conditions

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?
Same pattern with an empty dependency array.
Show solution
JSX Runnable
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#

API Integration quizMultiple choice Question 1 of 3

Does fetch reject its promise for a 404 response?

Score so far: 0/0

Want more? Practise all React MCQs · React interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.