Skip to content

3.1: Fetch and useEffect

Learning Objectives

  • Explain why data fetching belongs in an Effect instead of directly inside render
  • Fetch remote data with async/await using a safe useEffect pattern
  • Choose dependency arrays that match the values an Effect actually uses
  • Diagnose common useEffect mistakes such as infinite loops, stale values, and missing cleanup

React components are supposed to be pure: given the same props and state, they should return the same UI. A network request is not pure. It reaches outside your component and asks another system for data, so it belongs in an Effect.1

That makes useEffect a good fit for work like:

  • fetching API data
  • subscribing to native events
  • starting timers
  • cleaning up anything that should stop when the screen changes

It is not the right tool for everything. If you are only calculating a value from existing state, you usually do not need an Effect.2

The most common beginner pattern is: render once, fetch after render, then update state with the response.3

const [movies, setMovies] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
async function loadMovies() {
try {
setIsLoading(true);
setError(null);
const response = await fetch('https://reactnative.dev/movies.json');
const json = await response.json();
if (!ignore) {
setMovies(json.movies);
}
} catch (caughtError) {
if (!ignore) {
setError('Could not load movies.');
}
} finally {
if (!ignore) {
setIsLoading(false);
}
}
}
void loadMovies();
return () => {
ignore = true;
};
}, []);
Deeper Dive: Why is there `void` before `loadMovies()`?

loadMovies() is an async function, which means calling it returns a Promise.

loadMovies();

This will still run, but TypeScript or ESLint may warn that you created a Promise and then ignored it. That is often called a floating promise.

void loadMovies();

The void operator4 means: run this expression, but intentionally throw away its return value. In this case, it tells the reader and the linter, “Yes, I know this returns a Promise, and I am intentionally not awaiting it here.”

Why not just use await loadMovies()? Because the function you pass to useEffect should not be async. React expects an Effect to return either nothing or a cleanup function, not a Promise.

So this pattern:

  • starts the async work
  • avoids a floating-promise warning
  • keeps the Effect callback in the correct shape for React

Two details matter here:

  • The Effect callback itself is not marked async. Instead, create an async function inside the Effect and call it.
  • The cleanup sets ignore = true so an older request cannot update state after the component unmounts or the dependencies change.1
PatternWhen it runsCommon use
useEffect(() => { ... })After every renderRare; easy to misuse
useEffect(() => { ... }, [])Once after the first renderInitial fetch on mount
useEffect(() => { ... }, [query])On first render and when query changesRefetch data for a new search term
Diagram

This is the part students usually struggle with. Most useEffect bugs come from one of the patterns below.

If you omit the dependency array, the Effect runs after every render.

useEffect(() => {
loadMovies();
});

That is usually wrong for data fetching. If loadMovies() updates state, the component re-renders, the Effect runs again, and you can create a fetch loop.

This is another classic loop:

useEffect(() => {
async function loadMovies() {
const nextMovies = await getMovies();
setMovies(nextMovies);
}
void loadMovies();
}, [movies]);

The Effect sets movies, which changes movies, which reruns the Effect, which sets movies again. The dependency array should include the values the Effect reads from outside itself, not the state that it only writes.

This looks tempting, but it is the wrong shape:

useEffect(async () => {
const response = await fetch(url);
const json = await response.json();
setMovies(json.movies);
}, [url]);

React expects the Effect callback to return either nothing or a cleanup function. An async function always returns a Promise. The fix is to define an async function inside the Effect and call it.

Imagine a user changes screens before the request finishes. Without cleanup, a stale request may still try to update state.

useEffect(() => {
let ignore = false;
async function loadMovies() {
const nextMovies = await getMovies();
if (!ignore) {
setMovies(nextMovies);
}
}
void loadMovies();
return () => {
ignore = true;
};
}, []);

This small cleanup pattern prevents race-condition bugs where an older response arrives late and overwrites newer state.1

If a value can be calculated during render, do that directly instead of storing it in state with an Effect.

const visibleMovies = movies.filter((movie) => movie.releaseYear === year);

You do not need:

const [visibleMovies, setVisibleMovies] = useState<Movie[]>([]);
useEffect(() => {
setVisibleMovies(movies.filter((movie) => movie.releaseYear === year));
}, [movies, year]);

That extra Effect adds more state, more rerenders, and more opportunities for bugs.2

Deeper Dive: Why did my fetch run twice in development?

Some development setups intentionally run Effects more than once to help you notice missing cleanup. If you see two requests in development, do not rush to “silence” the extra run.

Instead, ask:

  • Does my Effect clean up correctly?
  • Would a stale response cause a bug?
  • Is my logic safe if the screen mounts, unmounts, and mounts again?

If your cleanup is correct and your Effect is idempotent, development checks become much less confusing.

When you are writing a fetch Effect from scratch, use this checklist:

  1. Create state for data, loading, and error.
  2. Write an async helper inside the Effect instead of marking the Effect callback async.
  3. Wrap the fetch in try/catch/finally so loading and error states always stay consistent.
  4. Return cleanup if the request result should be ignored after unmount or dependency changes.

Practice repairing a full Effect workflow before we move on to JSON parsing and loading indicators in 3.2.

  1. Click the external link icon (top-left of each Snack) to open in a new tab.
  2. Click the blue “Save” button to save to your account.
  3. Copy the Snack URL once completed and submit on Moodle.

The starter app has three issues to fix:

  • it fetches only on mount
  • loading/error states are incomplete
  • fast year switches can show stale results

Hint

Work in this order:

  1. Make the Effect react to selectedYear.
  2. Add isLoading and error handling around the request (try/catch/finally).
  3. Make an ignore cleanup flag so a slow older request cannot overwrite the latest selection.
  • useEffect is for synchronizing with systems outside render, such as APIs.
  • The usual fetch pattern is: render, run an Effect, fetch data, then update state.
  • Dependency arrays should list the outside values the Effect uses.
  • Cleanup matters when a request may finish after the screen changes.
  • Many bad Effects disappear once you calculate derived values directly during render.
  1. https://react.dev/reference/react/useEffect 2 3

  2. https://react.dev/learn/you-might-not-need-an-effect 2

  3. https://reactnative.dev/docs/network

  4. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/void