Module: React and Ecosystem
React and Ecosystem·097·11 MIN READ

097: useEffect + API

TOPICS COVERED: useEffect + API

Learning objective

Outcomes

You will use Effects only to synchronize with external systems, declare dependencies honestly, clean up synchronization, and fetch with loading/error handling plus stale-response protection.

I can distinguish rendering, events, and Effects and explain why production apps usually prefer framework data APIs or a client cache.

Prerequisites

Complete 096 and the JavaScript async/API fundamentals from 079–083. You should know Promises, fetch, HTTP status handling, AbortController, controlled selectors, and cleanup concepts 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

An Effect synchronizes a rendered component with a system outside React: a network resource, timer, event subscription, browser API, or third-party widget. It runs after commit. It is an escape hatch, not a general “after render” bucket.

Ask why code runs. Because the user clicked submit? Put it in that handler. Because a value can be calculated from props/state? Calculate during render. Because the visible component must remain synchronized with a network URL or browser subscription? 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 with the old snapshot, then setup runs with the new one. On removal, cleanup runs. In Strict Mode development, React runs an extra setup → cleanup → setup cycle to expose missing cleanup. Fix synchronization so that sequence is safe; do not disable Strict Mode or use a ref to conceal it.

No dependency array runs after every commit. [] means no reactive dependencies and setup on mount (plus the development check). [a, b] reruns when either differs by Object.is. Dependencies are determined by code, not personal preference. Do not suppress the linter; restructure code to remove a dependency only when it truly is non-reactive.

Beginner complete example: fetch tasks safely

This curriculum is a Vite client, so manual Effect + fetch is demonstrated against a deterministic local fixture. A public API belongs in optional comparison work, not in the required path.

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 saves network/browser work where cancellation is supported. The ignore flag also prevents an obsolete continuation from setting state, including work after an awaited parsing step. Cleanup does both. fetch does not reject for HTTP 404/500, so check response.ok before reading data.

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

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>
  );
}

Rapidly changing projectId starts a new synchronization. Cleanup invalidates the old one, so a slow old response cannot overwrite current project tasks. Do not make the Effect callback itself async; React expects it to return cleanup or nothing, not a Promise. Define and call an inner async function.

You might not need an Effect

These are wrong uses:

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

Derive count and visible tasks during render. Call postTask in submit. Unnecessary Effects create extra renders, stale frames, dependency complexity, and loops.

Subscriptions 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 external stores specifically, React's useSyncExternalStore is the purpose-built API, but the subscription example demonstrates mirrored cleanup.

Production data guidance

Manual fetching is educational but has limitations: no server data in initial HTML, easy network waterfalls, no caching/deduplication, repetitive race handling, and refetching after remount. In production, prefer a React framework's route/data APIs when using a framework, or a maintained client cache such as TanStack Query/SWR when appropriate. Those tools can preload, cache, deduplicate, retry, and coordinate requests. This Vite client curriculum intentionally shows the underlying Effect pattern so you understand synchronization and cleanup; it does not claim this is the best production architecture.

Optional advanced: extracting synchronization

Repeated fetching can be hidden behind a custom Hook with a declarative API, but a small Hook is still not a cache. It must retain dependency, cleanup, error, and race guarantees. Avoid wrapping every Effect merely to move lines. Extract when several components need one well-defined behavior and test it.

Do not solve changing object dependencies by adding useMemo automatically. Often create request options inside the Effect and depend on primitive inputs. Module constants such as API_URL are not reactive and need not 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 Network panel to inspect status, timing, and aborted requests. Throttle network and switch IDs quickly to test races. Log setup and cleanup together. Verify that setup → cleanup → setup leaves one active resource. Simulate non-OK responses and offline mode, not only success.

Accessibility and performance

Use concise loading status, useful error text, and a keyboard-operable retry button. Avoid indefinite spinners without text. Keep stable page headings to preserve orientation. If refreshing existing data, consider showing old content with a separate “Updating” status rather than replacing everything and moving focus.

Abort obsolete requests and avoid waterfalls. Production caches and framework APIs usually outperform ad hoc Effects. Do not memoize response mapping without measurement. Keep fetched raw/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 uses the selected userId, and [userId, requestKey] reruns the Effect for selection changes or retry. The response.ok check handles HTTP failures before parsing JSON. Keep both cleanup mechanisms. In production, prefer route/framework loading or a client cache for caching, request 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. Dependencies list every reactive value used; cleanup mirrors setup. Events remain in handlers and derived data remains in render. Manual Vite-client fetching must check HTTP status and abort or ignore stale results; production generally benefits 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 an Effect 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 explain 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 merely 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 plus 401, 500, empty, malformed JSON, and offline cases. Interview answer: events trigger writes, a loader/cache owns reads, React owns transient UI, and cache keys contain every result-changing input.


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 you need to understand cancellation and stale responses. It is not the default data architecture for the later production examples. Those 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 says a reactive value is missing, first change the code structure rather than silencing the rule.

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 Effect sentence 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.

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/cleanup.

The correct goal is not "make it run once." The goal is to make every setup have a correct cleanup when cleanup is required.

Effects do not run during server rendering

Effects are client-side synchronization.

If a page's essential data only begins in an Effect:

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

  return <Spinner />;
}

the server cannot render that fetched data using this Effect.

Modern routers/frameworks/server components can start 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 user rapidly changes:

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

stale A can overwrite B.

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 is 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/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 every render.

Often best:

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

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

Now dependencies are meaningful primitives.

Function dependency

Problem:

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

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

The function identity changes every render.

Rather than automatically useCallback, put the helper inside the Effect if it only serves the Effect.

Document synchronization

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

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

Whether restoring the previous title is correct depends on the routing architecture. A router/meta system may be a 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 "subscription lifetime does not depend on this callback's latest implementation."

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]);

This illustrates why one giant Effect for all widget behavior can be hard to reason about.

"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, often module/provider architecture is 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 to represent all four.

Effects and stale closures

Each render creates new closures.

This:

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

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

captures initial count.

Solutions depend on semantics:

  • add count dependency and recreate timer;
  • use a functional state updater;
  • store external mutable value in ref if 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]);

Loop.

The real issue is duplicated state.

Fetch in every component

Multiple components manually fetching same server data leads to:

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

This motivates query caches.

Cleanup missing

A WebSocket/subscription that remains active after component removal creates leaks and ghost updates.

Exercises

  1. Convert a derived-data Effect to 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

Explain:

  • synchronization contract;
  • setup/cleanup lifecycle;
  • why dependencies are descriptive;
  • why manual fetch races occur;
  • why Effects do not run on server;
  • why query libraries reduce Effect-based server-data code.