Skip to content

Fetching Data with useEffect

ZeroBudget is a localStorage app — no API calls needed. But React apps frequently fetch data from servers, and useEffect is the right place to do it. This lesson covers the pattern clearly so you can apply it in future projects.

Fetching data is a side effect — it reaches outside the component to an external system. Putting it in useEffect with the correct dependency array ensures it runs at the right time and does not block rendering.

Never fetch at the top level of a component body (outside useEffect). That would trigger a new request on every render, including the re-renders caused by state updates from the previous fetch.

const [movies, setMovies] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('https://api.example.com/movies')
.then(res => {
if (!res.ok) throw new Error('Request failed');
return res.json();
})
.then(data => {
setMovies(data.results);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);

Three state variables handle the three possible UI states:

  • loading: true → show a spinner or skeleton
  • error: 'message' → show an error message
  • movies: [...] → show the list

You cannot make the useEffect callback itself async — it must return either nothing or a cleanup function, and async functions always return a Promise.

Instead, define an async function inside the effect and call it immediately:

useEffect(() => {
async function fetchMovies() {
try {
setLoading(true);
const res = await fetch('https://api.example.com/movies');
if (!res.ok) throw new Error('Request failed');
const data = await res.json();
setMovies(data.results);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchMovies();
}, []);

Pass the value the fetch depends on into the dependency array. The effect re-runs when it changes:

useEffect(() => {
async function search() {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
}
if (query) search();
}, [query]);

If the user triggers a new fetch before the previous one finishes, the old response can arrive after the new one and overwrite it. Use an AbortController to cancel in-flight requests:

useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err.message); });
return () => controller.abort();
}, [dependency]);

The cleanup function aborts the previous fetch before the effect re-runs.

Using a free public API like https://jsonplaceholder.typicode.com/todos:

  1. Create a component TodoList with loading, error, and todos state.
  2. Fetch the todo list in useEffect using async/await inside a named function.
  3. Show a loading message while fetching, an error message if the fetch fails, and the list when data arrives.
  4. Add an AbortController and return the cleanup function to abort on re-run or unmount.
  • Fetch data inside useEffect — never at the top level of a component.
  • Three state variables cover the three UI states: loading, error, and data.
  • Use an async function defined inside the effect to use await.
  • Add dependencies to the array to re-fetch when they change.
  • Return a cleanup that calls controller.abort() to cancel stale in-flight fetches.