FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·097·14 MIN READ

097: useEffect + API

TOPICS COVERED: useEffect + API

Learning objective

Outcomes

You will use Effects for their actual job: synchronizing committed UI with external systems. You will declare dependencies honestly, undo synchronization in cleanup, and fetch data with explicit loading and error states while protecting the screen from stale responses.

You should be able to separate rendering, event handling, and Effects, and explain why a production application will usually prefer framework data APIs or a client-side cache for server data.

Prerequisites

Complete 096 and the JavaScript async/API fundamentals from 079–083. You should already be comfortable with Promises, fetch, HTTP status handling, AbortController, controlled selectors, and cleanup ideas such as removing an event listener.

Retrieval practice

  1. Why is form submission an event rather than an Effect?
  2. Why should filtered tasks be calculated during render?
  3. What must happen to an older request when its query is no longer current?

Content to cover

effects; dependency array; cleanup concept; data fetching; loading/error state.

Terms and mental model

The confusion usually starts with the phrase “after render.” An Effect is not a general-purpose place for anything that should happen after React renders. It synchronizes a committed component with something outside React: a network resource, timer, event subscription, browser API, or third-party widget. React runs it after the commit, making it an escape hatch for synchronization rather than another rendering phase.

When deciding where code belongs, ask why it runs. If the reason is “the user clicked submit,” put it in the event handler. If the value can be calculated from props or state, calculate it during render. If the visible component must stay synchronized with a network URL, browser subscription, timer, or other external resource, use an Effect.

Effect lifecycle

jsx
useEffect(() => {
  const connection = connect(roomId);
  return () => connection.disconnect();
}, [roomId]);

After the first commit, setup runs. When roomId changes, cleanup runs using the old render’s snapshot, and setup then runs with the new one. When the component is removed, cleanup runs again. In development Strict Mode, React deliberately performs an extra setup → cleanup → setup cycle to expose missing cleanup. Make that sequence safe; do not disable Strict Mode or use a ref to hide the problem.

With no dependency array, an Effect runs after every commit. [] says that the setup has no reactive dependencies and therefore runs on mount, with the development check still possible. [a, b] reruns when either value differs according to Object.is. Dependencies come from the code the setup reads, not from a preferred execution frequency. Do not suppress the linter to force a schedule. Restructure the code to remove a dependency only when that value is genuinely non-reactive.

Beginner complete example: fetch tasks safely

This curriculum is a Vite client, so the first manual Effect plus fetch example uses a deterministic local fixture. That keeps the required path reliable. A public API is useful for optional comparison, but it should not be a prerequisite for completing the lesson.

jsx
import { useEffect, useState } from 'react';

const localTasks = [
  { id: 'local-1', title: 'Trace Effect cleanup', completed: false },
  { id: 'local-2', title: 'Test an aborted request', completed: true },
];

function getLocalTasks({ signal }) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve(localTasks), 40);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      const error = new DOMException('Request aborted', 'AbortError');
      reject(error);
    }, { once: true });
  });
}

export default function RemoteTasks() {
  const [tasks, setTasks] = useState([]);
  const [status, setStatus] = useState('loading');
  const [error, setError] = useState('');
  const [requestKey, setRequestKey] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    async function loadTasks() {
      setStatus('loading');
      setError('');
      try {
        const data = await getLocalTasks({
          signal: controller.signal,
        });
        if (!ignore) {
          setTasks(data.map((item) => ({
            id: item.id,
            title: item.title,
             completed: item.completed,
          })));
          setStatus('success');
        }
      } catch (caughtError) {
        if (!ignore && caughtError.name !== 'AbortError') {
          setError(caughtError.message || 'Tasks could not be loaded.');
          setStatus('error');
        }
      }
    }

    loadTasks();
    return () => {
      ignore = true;
      controller.abort();
    };
  }, [requestKey]);

  if (status === 'loading') return <p role="status">Loading tasks…</p>;
  if (status === 'error') {
    return (
      <div role="alert">
        <p>{error}</p>
        <button type="button" onClick={() => setRequestKey((key) => key + 1)}>
          Retry loading tasks
        </button>
      </div>
    );
  }
  if (tasks.length === 0) return <p>No remote tasks were found.</p>;

  return (
    <section aria-labelledby="remote-heading">
      <h2 id="remote-heading">Imported tasks</h2>
      <ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>
    </section>
  );
}

The controller cancels network or browser work where cancellation is supported. The ignore flag is still needed: an obsolete continuation may resume after an await or after parsing has completed. Cleanup performs both jobs. Also remember that fetch does not reject merely because the server returned HTTP 404 or 500. Check response.ok before reading the data.

Retry is an event. The button changes requestKey; the Effect then synchronizes the displayed data with the current request key and URL. A POST that creates a task is different: the submit handler should perform that user-triggered request, not an Effect.

Intermediate: dependency-driven project selection

jsx
import { useEffect, useState } from 'react';

function ProjectTasks({ projectId }) {
  const [tasks, setTasks] = useState([]);
  const [status, setStatus] = useState('loading');
  const [error, setError] = useState('');

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    async function load() {
      setStatus('loading');
      setError('');
      const response = await fetch(`/api/projects/${projectId}/tasks`, {
        signal: controller.signal,
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const nextTasks = await response.json();
      if (!ignore) {
        setTasks(nextTasks);
        setStatus('success');
      }
    }

    load().catch((error) => {
      if (!ignore && error.name !== 'AbortError') {
        setError(error.message);
        setStatus('error');
      }
    });
    return () => {
      ignore = true;
      controller.abort();
    };
  }, [projectId]);

  if (status === 'loading') return <p role="status">Loading project tasks...</p>;
  if (status === 'error') return <p role="alert">{error}</p>;
  if (tasks.length === 0) return <p>No tasks found for this project.</p>;

  return (
    <ul>
      {tasks.map((task) => <li key={task.id}>{task.title}</li>)}
    </ul>
  );
}

When projectId changes rapidly, each change starts a new synchronization. Cleanup invalidates the previous one, so a slow response for the old project cannot replace the current project’s tasks. Do not mark the Effect callback itself async: React expects that callback to return cleanup or nothing, not a Promise. Define an inner async function and call it instead.

You might not need an Effect

These patterns use Effects for work that belongs elsewhere:

jsx
 useEffect(() => setOpenCount(tasks.filter((t) => !t.completed).length), [tasks]);
useEffect(() => setVisibleTasks(filterTasks(tasks, filter)), [tasks, filter]);
useEffect(() => { if (submitted) postTask(draft); }, [submitted, draft]);

The count and visible tasks are derived values, so calculate them during render. postTask belongs in the submit handler. Unnecessary Effects add an extra render, create stale frames and dependency questions, and can form loops.

Subscriptions are a real synchronization problem and do need Effects:

jsx
useEffect(() => {
  function handleOnline() { setOnline(navigator.onLine); }
  window.addEventListener('online', handleOnline);
  window.addEventListener('offline', handleOnline);
  return () => {
    window.removeEventListener('online', handleOnline);
    window.removeEventListener('offline', handleOnline);
  };
}, []);

For an external store, React’s useSyncExternalStore is the purpose-built API. This smaller example is still useful because it makes mirrored subscription cleanup visible.

Production data guidance

Manual fetching teaches the underlying mechanics, but it has real limitations: fetched data is absent from initial server HTML, requests can form waterfalls, there is no built-in caching or deduplication, race handling gets repeated, and remounting can refetch. In production, prefer route or data APIs from the React framework you are using, or a maintained client cache such as TanStack Query/SWR when that fits the application. Such tools can preload, cache, deduplicate, retry, and coordinate requests. This Vite curriculum intentionally exposes the Effect pattern so you understand synchronization and cleanup; it is not presenting that pattern as the best production architecture.

Optional advanced: extracting synchronization

Repeated fetching can sit behind a custom Hook with a declarative API. A small Hook is not automatically a cache: it still needs correct dependencies, cleanup, error handling, and race protection. Do not wrap every Effect just to move lines elsewhere. Extract a Hook when multiple components need one well-defined behavior, and test that behavior.

Do not respond to changing object dependencies by automatically adding useMemo. Often the request options can be created inside the Effect while the dependency list contains primitive inputs. Module constants such as API_URL are not reactive and do not need to be dependencies.

Mistakes and debugging

  • Effect as event handler causes actions on remount or Back navigation.
  • Missing cleanup allows stale responses or duplicate subscriptions.
  • Suppressed dependency warning creates stale closures.
  • async Effect callback returns a Promise instead of cleanup.
  • Treating HTTP 500 as success because response.ok was not checked.
  • Showing abort as an error confuses normal cleanup with failure.
  • Infinite loop: Effect sets state that changes its own dependency every run.
  • Disabling Strict Mode hides rather than fixes cleanup defects.
  • Adding useMemo/useCallback to appease dependencies instead of simplifying setup.

Use the Network panel to inspect status, timing, and aborted requests. Throttle the network and switch IDs quickly to force the race. Log setup and cleanup together. A healthy result is setup → cleanup → setup with only one active resource afterward. Also simulate non-OK responses and offline mode; a successful request alone does not exercise the failure paths.

Accessibility and performance

Expose a concise loading status, useful error text, and a keyboard-operable retry button. Do not leave users with an indefinite spinner that has no text. Keep page headings stable so the user retains orientation. When refreshing existing data, consider preserving the old content and adding an “Updating” status instead of replacing everything and moving focus.

Abort obsolete requests and avoid waterfalls. Framework APIs and production caches generally outperform ad hoc Effects for server data. Do not memoize response mapping without measurement. Keep fetched raw or normalized data minimal, and derive view filters during render.

Practice

Fetch API data into a React screen.

Tiered exercises

Core: Fetch tasks, check response.ok, and display loading/error/empty/success.

Stretch: Add retry and cleanup using AbortController plus an ignore flag.

Challenge: Add a controlled user/project selector, race requests under throttling, and prove stale responses cannot win. Explain the production alternative.

For dependency-driven selection, use the same cleanup and status pattern with a userId prop:

jsx
import { useEffect, useState } from 'react';

function RemoteTasks({ userId }) {
  const [tasks, setTasks] = useState([]);
  const [status, setStatus] = useState('loading');
  const [error, setError] = useState('');
  const [requestKey, setRequestKey] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    async function loadTasks() {
      setStatus('loading');
      setError('');
      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/todos?userId=${userId}`,
          { signal: controller.signal },
        );
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        if (!ignore) {
          setTasks(data.map((item) => ({
            id: item.id,
            title: item.title,
             completed: item.completed,
          })));
          setStatus('success');
        }
      } catch (caughtError) {
        if (!ignore && caughtError.name !== 'AbortError') {
          setError(caughtError.message || 'Tasks could not be loaded.');
          setStatus('error');
        }
      }
    }

    loadTasks();
    return () => {
      ignore = true;
      controller.abort();
    };
  }, [userId, requestKey]);

  if (status === 'loading') return <p role="status">Loading tasks...</p>;
  if (status === 'error') {
    return (
      <div role="alert">
        <p>{error}</p>
        <button type="button" onClick={() => setRequestKey((key) => key + 1)}>
          Retry loading tasks
        </button>
      </div>
    );
  }
  if (tasks.length === 0) return <p>No tasks were found for this user.</p>;

  return (
    <section aria-labelledby="remote-heading">
      <h2 id="remote-heading">Imported tasks</h2>
      <ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>
    </section>
  );
}

export default function App() {
  const [userId, setUserId] = useState('1');
  return (
    <main>
      <h1>Remote Task Manager</h1>
      <label>
        User
        <select value={userId} onChange={(e) => setUserId(e.target.value)}>
          <option value="1">User 1</option>
          <option value="2">User 2</option>
          <option value="3">User 3</option>
        </select>
      </label>
      <RemoteTasks userId={userId} />
    </main>
  );
}

The URL includes the selected userId, and [userId, requestKey] reruns the Effect for either a selection change or retry. The response.ok check rejects HTTP failures before JSON parsing. Keep both cleanup mechanisms: abort where possible and ignore any continuation that still completes. In production, a route loader/framework API or client cache is usually the better owner for caching, deduplication, preloading, and retries.

Exit questions

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. Can you explain the code without reading it line by line?

Recap

Effects synchronize committed UI with external systems. Their dependencies list every reactive value used by setup, and cleanup mirrors setup. Events stay in handlers, while derived data stays in render. Manual Vite-client fetching must check HTTP status and abort or ignore stale results; production applications generally benefit from framework data APIs or a client cache.

Official references

Interview questions

  1. What qualifies as an external system, and why is an Effect not an “after render” event handler?
  2. Why do both AbortController and an ignore/request-identity guard matter?
  3. What are the limitations of manual fetch Effects in production?

Strong answer: Effects synchronize committed UI with external resources. Cleanup stops or invalidates obsolete work, response.ok handles HTTP failures, and a framework loader or query cache usually adds caching, deduplication, preloading, and invalidation.

Effects, stale closures, and request ownership

An Effect synchronizes with an external system; it is not a general lifecycle bucket. Do not use one to calculate a value that can be derived during render or to respond to a user event that belongs in an event handler.

Own each request with AbortController or an equivalent request identity. Ignore or abort stale results, check HTTP status, validate response data, and expose loading, empty, error, retry, and success states. Compare manual fetching with a route loader or cache, and be able to say who owns invalidation.

Interview case: server state ownership

Server state is remote, shared, asynchronous, cacheable, and potentially stale. Local state owns drafts and filters; a query cache owns fetched tasks, freshness, retries, cancellation, and invalidation. Do not copy cached tasks into useState simply to derive a filter.

If request A loads Alice’s tasks and the user switches to Bob, cleanup aborts A and invalidates its continuation. Test out-of-order deferred promises as well as 401, 500, empty, malformed JSON, and offline cases. A strong interview answer is: events trigger writes, a loader or cache owns reads, React owns transient UI, and cache keys contain every input that changes the result.


2026 depth expansion: the first rule of Effects is to ask whether you need one

Before writing an Effect, classify the logic:

LogicCorrect home
derive filtered tasksrender
submit a formevent/action
update state from previous statesetter/reducer
synchronize a video playerEffect
subscribe to WebSocketEffect
listen to browser eventEffect
fetch route datausually router/framework/query cache
synchronize URLrouter/navigation API

Manual Effect-based fetching is taught because cancellation and stale responses are mechanisms you need to understand. It is not the default data architecture for later production examples, which use route loaders or TanStack Query v5.

Dependency arrays are descriptions

Do not “choose” dependencies to control execution frequency. The setup code determines the reactive dependencies. If the linter reports a missing reactive value, change the code structure before considering a lint suppression.

The advanced Effects lesson later covers useEffectEvent from React 19.2, useLayoutEffect, external subscriptions, and removing unnecessary Effects.


Deep dive: Effects are synchronization, not application orchestration

A useful way to describe an Effect is:

Keep X external system synchronized with Y reactive value.

Examples:

text
Keep document title synchronized with current task name.
Keep WebSocket room synchronized with roomId.
Keep media player playback synchronized with isPlaying.
Keep browser event subscription synchronized with component lifetime.

This is more useful than saying “run this after the page loads,” because it identifies the contract that setup and cleanup must maintain.

Poor Effect sentence:

text
Run this code after the page loads.

"After load" does not identify the synchronization contract.

The lifecycle in detail

jsx
useEffect(() => {
  const connection = connect(roomId);

  return () => {
    connection.disconnect();
  };
}, [roomId]);

Suppose:

text
roomId A
→ component commits
→ setup A

roomId changes to B
→ next render commits
→ cleanup A
→ setup B

component unmounts
→ cleanup B

Development Strict Mode additionally stress-tests setup and cleanup. The goal is not to make an Effect “run once.” The goal is for every setup to have correct cleanup whenever the external resource requires it.

Effects do not run during server rendering

Effects are client-side synchronization. If essential page data begins only inside an Effect:

jsx
function Page() {
  useEffect(() => {
    fetch('/api/page').then(...);
  }, []);

  return <Spinner />;
}

the server cannot use that Effect to render the fetched data. Modern routers, frameworks, and server components can begin data work before client Effects.

Race conditions in manual fetch

Naive:

jsx
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((r) => r.json())
    .then(setUser);
}, [userId]);

If the user changes quickly:

text
A request starts
B request starts
B returns
A returns later

stale A can overwrite B. The request that finishes last is not necessarily the request that represents the current UI.

Use cancellation:

jsx
useEffect(() => {
  const controller = new AbortController();

  async function load() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      setUser(await response.json());
    } catch (error) {
      if (error.name !== 'AbortError') {
        setError(error);
      }
    }
  }

  load();

  return () => controller.abort();
}, [userId]);

This remains an important learning exercise, but TanStack Query later owns this class of server-state lifecycle.

Dependency debugging

If the lint rule says:

text
React Hook useEffect has a missing dependency

do not immediately add:

jsx
// eslint-disable-next-line react-hooks/exhaustive-deps

Instead ask:

  1. Is the Effect necessary?
  2. Is event logic incorrectly placed in it?
  3. Can derived data be calculated during render?
  4. Is an object or function unnecessarily created outside the Effect?
  5. Is this genuinely non-reactive event logic that useEffectEvent should represent?
  6. Should the synchronization be in a custom Hook?

Object dependency

Problem:

jsx
const config = { roomId, url };

useEffect(() => {
  connect(config);
}, [config]);

config is a new object on every render, so its identity changes even when its fields do not.

Often best:

jsx
useEffect(() => {
  const config = { roomId, url };
  const connection = connect(config);

  return () => connection.disconnect();
}, [roomId, url]);

The dependency list now describes meaningful primitive inputs.

Function dependency

Problem:

jsx
function createOptions() {
  return { roomId, url };
}

useEffect(() => {
  const connection = connect(createOptions());
  ...
}, [createOptions]);

The function identity changes every render. Rather than automatically adding useCallback, put a helper inside the Effect when it serves only that Effect.

Document synchronization

jsx
useEffect(() => {
  const previous = document.title;
  document.title = `${task.title} · Tasks`;

  return () => {
    document.title = previous;
  };
}, [task.title]);

Restoring the previous title may or may not be correct for a particular routing architecture. A router or metadata system may be the better owner. The exercise is to reason about ownership, not memorize one snippet.

Browser subscription

jsx
useEffect(() => {
  function handleKeyDown(event) {
    if (event.key === 'Escape') {
      onClose();
    }
  }

  window.addEventListener('keydown', handleKeyDown);

  return () => {
    window.removeEventListener('keydown', handleKeyDown);
  };
}, [onClose]);

If onClose changes often, later useEffectEvent can express that subscription lifetime does not depend on the latest implementation of this callback.

External widgets

jsx
useEffect(() => {
  const map = new MapWidget(containerRef.current, {
    center,
  });

  return () => {
    map.destroy();
  };
}, []);

Then a separate synchronization Effect can update center:

jsx
useEffect(() => {
  mapRef.current?.setCenter(center);
}, [center]);

Separating resource lifetime from property updates is easier to reason about than putting every widget operation into one giant Effect.

"Run once" is not a semantic category

An empty dependency array:

jsx
useEffect(() => {
  ...
}, []);

means the Effect does not read reactive dependencies that should cause resynchronization. It does not mean:

  • guaranteed exactly once in development;
  • safe place for arbitrary initialization;
  • replacement for module initialization;
  • place to send one-time payments or analytics without idempotency.

For once-per-application infrastructure, module or provider architecture is often clearer.

API loading state model

If you do manually fetch:

jsx
const [state, setState] = useState({
  status: 'pending',
  data: null,
  error: null,
});

Distinguish:

text
pending
error
success-empty
success-data

Do not use data === null as a substitute for all four states.

Effects and stale closures

Each render creates new closures. This Effect captures the initial count because its empty dependency array never replaces the callback:

jsx
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);

  return () => clearInterval(id);
}, []);

The right solution depends on the semantics:

  • add count as a dependency and recreate the timer;
  • use a functional state updater;
  • store an external mutable value in a ref when the UI does not depend on it;
  • use useEffectEvent for latest committed event logic.

There is no universal stale-closure fix.

Failure clinic

Effect sets state that is also a dependency

jsx
useEffect(() => {
  setOptions({ sort });
}, [options, sort]);

This loops. The underlying problem is duplicated state.

Fetch in every component

When several components manually fetch the same server data, the result is often:

  • duplicate requests;
  • inconsistent retry;
  • inconsistent stale data;
  • no shared invalidation.

That is the problem query caches are designed to solve.

Cleanup missing

A WebSocket or subscription left active after component removal creates leaks and ghost updates.

Exercises

  1. Convert a derived-data Effect to a render calculation.
  2. Add AbortController to a manual fetch.
  3. Reproduce a stale response race and fix it.
  4. Build a browser resize subscription with cleanup.
  5. Refactor an object dependency.
  6. Identify five things in a project that should not be Effects.

Mastery check

When debugging a fetch Effect, inspect the whole ownership chain rather than looking only at the final render:

  • Which render supplied the current query or identifier?
  • Which request belongs to that render?
  • Which cleanup invalidates or aborts the request when the value changes?
  • Which branch handles a non-OK HTTP response?
  • Which branch handles an empty but successful response?
  • Which state update is prevented after the request becomes obsolete?

The Network panel can confirm that the browser attempted the expected URL and show whether a request was aborted. Logs around setup and cleanup can confirm that a subscription or timer is not duplicated. A stale result that still appears on screen usually points to missing request ownership, an incomplete dependency list, or a state update that is not protected after an await.

That same reasoning applies beyond fetch:

  • a timer belongs to the render that created it and must be cleared;
  • a subscription belongs to the component lifetime and must be removed;
  • a widget instance must be destroyed when its owner disappears.

Explain:

  • synchronization contract;
  • setup/cleanup lifecycle;
  • why dependencies are descriptive;
  • why manual fetch races occur;
  • why Effects do not run on the server;
  • why query libraries reduce Effect-based server-data code.
Reader page: /react/lesson/097/useeffect-api