The proper use of useEffect#

useEffect is for side effects: stepping outside React to sync a component with an external system (network, timers, subscriptions, browser APIs). The typical case is async work: a promise whose result arrives after the render.

Ref: Synchronizing with Effects

The standard shape#

It’s literally the first page of the official useEffect documentation.

useEffect(() => {
  // 1. setup: start the side effect
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .then(setUser)
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });

  // 2. cleanup: stop or neutralize it
  return () => controller.abort();
}, [userId]); // 3. dependencies: re-run when they change

fetch rejects only on network errors: an HTTP error (4xx, 5xx) still resolves, so check res.ok and throw. All the examples below do it.

Implement the cleanup. React runs it before re-running the effect (when a dependency changes) and on unmount. Without it you get:

  • Race conditions: userId goes from 1 to 2, the response for 1 arrives after the one for 2 and overwrites it.
  • Performance degradation: listeners, intervals and subscriptions pile up on every re-run, and requests keep running for unmounted components.

A complex case: reload an API call#

A list with a “Reload” button, a sort of manual retry. This is often written wrong.

Wrong#

function Users() {
  const [loading, setLoading] = useState(false);
  const [resp, setResp] = useState(null);
  const [error, setError] = useState(null);

  const load = useCallback(() => {
    setLoading(true);
    fetch('/api/users')
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((resp) => {
        setResp(resp);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

  useEffect(() => {
    load();
  }, [load]);

  return <button onClick={load}>Reload</button>;
}
  • Race condition: click twice, the two requests run together, and the slowest one wins, even if it’s the oldest.
  • Inconsistent state: loading becomes false when the first request ends, while the second one is still running.
  • No cleanup: requests keep running after unmount, and the fetch is started from two places (the effect and the handler).

The handler doesn’t fetch, it only asks for a new load by changing a trigger. The effect does the fetch, and the response is stored together with the trigger that produced it:

function Users() {
  const [trigger, setTrigger] = useState(0);
  const [state, setState] = useState({ trigger: null, resp: null, error: null });
  const loading = state.trigger !== trigger;

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

    fetch('/api/users', { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((resp) => setState({ trigger, resp, error: null }))
      .catch((error) => {
        if (error.name !== 'AbortError') setState({ trigger, resp: null, error });
      });

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

  return (
    <>
      <button onClick={() => setTrigger((t) => t + 1)}>
        {loading ? 'Loading…' : 'Reload'}
      </button>
      {/* render state.resp or state.error */}
    </>
  );
}
  • No race condition: a new click changes trigger, React runs the cleanup, the previous request is aborted, and only the latest one can update the state.
  • loading is computed, not stored: it’s true until the state holds the response for the current trigger, so it can’t go out of sync.
  • Everything follows the React flow: event → state change → render → effect → state change. While reloading, the previous resp stays on screen until the new one arrives.
  • StrictMode runs the effect twice in development: the first request is aborted, and nothing breaks.

Same pattern: search as you type#

Instead of a trigger, use the search itself: the user types in an input, every change updates search, and every new search calls the API.

function UserSearch() {
  const [search, setSearch] = useState(''); // empty search = all users
  const [state, setState] = useState({ search: null, resp: null, error: null });
  const loading = state.search !== search;
  // the response only when it matches the current search, undefined while loading
  const data = loading ? undefined : state.resp;

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

    // debounce: wait 300ms after the last keystroke before calling the API
    const timeout = setTimeout(() => {
      fetch(`/api/users?q=${encodeURIComponent(search)}`, { signal: controller.signal })
        .then((res) => {
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res.json();
        })
        .then((resp) => setState({ search, resp, error: null }))
        .catch((error) => {
          if (error.name !== 'AbortError') setState({ search, resp: null, error });
        });
    }, 300);

    // without the debounce, the cleanup is just:
    // return () => controller.abort();
    return () => {
      clearTimeout(timeout); // the request has not started yet: skip it
      controller.abort(); // the request is running: abort it
    };
  }, [search]);

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      {loading && <Spinner />}
      {/* render data or state.error */}
    </>
  );
}
  • Debounce: typing a, ad, ada quickly clears the pending timeouts, so only one request starts, for ada.
  • No race condition: if the user types again while a request is running, the cleanup aborts it. You are sure that state.resp is the response for the latest search, never an older one arriving late.
  • state.search tells you which search the response belongs to, so loading is computed again, and it’s true during the debounce too.
  • data is computed too: state.resp keeps the previous response while loading, data doesn’t. No need to call setState({ resp: null }) at the beginning of the effect: the effect runs after the render, so you get one render with the stale data plus an extra render. Computed during render, data is undefined in the same render in which search changes: fast feedback, always in sync with loading.
  • In a custom hook (e.g. useUserSearch), data is what you return: undefined while loading, the latest response otherwise.

Tip: keep the effect short, store the raw data#

When things get complex, this mindset helps a lot: the effect only gets the data and stores it raw in a state. Everything else is derived from the raw state during render (with useMemo if necessary).

Avoid computing derived data inside the effect and spreading it over multiple states:

// Wrong: the effect transforms the response and discards the rest
.then((resp) => {
  setUsers(resp.users.filter((u) => u.active));
  setTotal(resp.total);
});

// Right: the effect stores the raw response...
.then((resp) => setState({ search, resp, error: null }));

// ...and the rest is derived during render
const activeUsers = useMemo(
  () => data?.users.filter((u) => u.active) ?? [],
  [data],
);

As the code evolves, you will need some data that an earlier transformation discarded (e.g. the inactive users). With the transformations in the effect, you have to add a new state for it and grow the effect. With the raw data in state, it’s just one more derived variable.

No cleanup? It’s a smell#

A few effects legitimately have nothing to clean up: setting document.title, focusing an input on mount, logging a page view. They are the exception. If you can’t write a meaningful cleanup, there is probably no external system to sync with, and you probably don’t need an effect. Ask yourself:

  • Am I computing a value from props or state? → Compute it during render (useMemo if it’s expensive).
  • Am I reacting to a user action? → Put the logic in the event handler.
  • Am I copying a prop or a param into state? → See the examples below.
  • Does the effect only call setState? → It costs an extra render, and the first render shows a stale value.

Ref: You Might Not Need an Effect

Smell: a POST in an effect#

A user action (e.g. clicking “Create”) sets a state, and an effect sends the POST:

const [toCreate, setToCreate] = useState(null);

useEffect(() => {
  if (!toCreate) return;
  const controller = new AbortController();

  fetch('/api/projects', {
    method: 'POST',
    body: JSON.stringify(toCreate),
    signal: controller.signal,
  })
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .then(setCreated);

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

// ...
<button onClick={() => setToCreate({ name })}>Create</button>;

The cleanup is there, but why would you abort a POST? If the request has been sent and the response hasn’t arrived yet, aborting doesn’t stop the backend: the project gets created anyway. Worse, you lose the response, so you don’t know whether it succeeded or not. And in development, StrictMode runs the effect twice, so it may send the POST twice.

So why not just send the POST in the event handler?

const [saving, setSaving] = useState(false);

const onCreate = async () => {
  setSaving(true);
  try {
    const res = await fetch('/api/projects', {
      method: 'POST',
      body: JSON.stringify({ name }),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    setCreated(await res.json());
  } catch (error) {
    setError(error);
  } finally {
    setSaving(false);
  }
};

// ...
<button onClick={onCreate} disabled={saving}>
  Create
</button>;
  • It runs once per click, and you always get the response.
  • disabled={saving} prevents a double POST from a double click.

The exception: requests whose result you don’t care about (at least not to show it to the user), like sending monitoring events.

Copy vs join#

Most effects without a cleanup exist to copy a value from props or params into state, and then keep the copy up to date. This is the principle behind the following examples: a copy means two sources (the props/params and the state) that you have to keep aligned by hand. Instead, join them: keep in state only what the user changed, and compute the rest during render.

Each example starts from the wrong version, so you learn to recognize the smell (an effect that only syncs a value into state), then shows how to remove the effect by joining the sources.

Example 1: override a value on prop change#

Wrong#

function NameForm({ value, onSave }) {
  const [name, setName] = useState(value);

  useEffect(() => {
    setName(value);
  }, [value]);

  // ...
}
  • Two sources of truth (the prop and the state), kept in sync by hand.
  • When value changes, the component first renders the stale name, then renders again.
  • No cleanup: smell.

Right#

Keep in state only what the user changed, initially null, and fall back to the prop:

function NameForm({ value, onSave }) {
  const [edited, setEdited] = useState(null); // null = not changed yet
  const name = edited ?? value;
  const changed = name !== value;

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSave(name);
      }}
    >
      <input value={name} onChange={(e) => setEdited(e.target.value)} />
      <button type="submit" disabled={!changed}>
        Save
      </button>
    </form>
  );
}
  • Until the user types, the component follows the prop.
  • changed is computed too, no extra state: it enables the save button only when there is something to save.
  • Prefer ?? over ||: with ||, an emptied input ('') would fall back to the prop.

Ref: Adjusting some state when a prop changes

Example 2: default value#

The value lives in the URL params (here with React Router’s useSearchParams), with a default when it’s missing.

Wrong#

const [searchParams, setSearchParams] = useSearchParams();
const [sort, setSort] = useState('newest');

useEffect(() => {
  setSort(searchParams.get('sort') ?? 'newest');
}, [searchParams]);

You might be tempted to write useState(searchParams.get('sort')) instead, but why create a state at all? The value is already in the params, so why duplicate it? Besides, the initial value is read only on the first render: when the params change later (back button, a link), the state goes stale.

Right#

The params are the source of truth: read the value with its default, and write it back on change.

function SortSelect() {
  const [searchParams, setSearchParams] = useSearchParams();
  const sort = searchParams.get('sort') ?? 'newest';

  const onChange = (e) => {
    setSearchParams((prev) => {
      prev.set('sort', e.target.value);
      return prev;
    });
  };

  return (
    <select value={sort} onChange={onChange}>
      <option value="newest">Newest</option>
      <option value="oldest">Oldest</option>
    </select>
  );
}

No state, no effect: one source, always aligned.

Example 2b: dynamic default value#

Sometimes the default is not a constant but something dynamic, e.g. coming from an API: a filter by project id, where the id can be set in the URL, and when it’s missing the first project of a list fetched from the API is used.

function ProjectFilter() {
  const [searchParams, setSearchParams] = useSearchParams();
  const { data: projects, isLoading } = useProjects(); // list from the API

  if (isLoading) return <Spinner />;

  const projectId = searchParams.get('projectId') ?? projects[0]?.id;

  const onChange = (e) => {
    setSearchParams((prev) => {
      prev.set('projectId', e.target.value);
      return prev;
    });
  };

  // ...
}

Don’t write the default into the URL: a page without projectId means “use the default”, so the URL changes only when the user picks a project.

Example 3: defaults plus user changes#

A form with initial values from the params, which the user can edit:

  • the params are the base: map them to the form fields, pick only the keys you need and add fallbacks;
  • the state holds only the edits;
  • the data is the merge: the edits override the base where needed.
function ProfileForm({ params, onSubmit }) {
  const [edits, setEdits] = useState({}); // only the fields the user changed

  // base: form fields from the params, with fallbacks
  const defaults = {
    name: params.the_name ?? '',
    email: params.email ?? '',
    role: params.role ?? 'viewer',
  };

  // data: the edits override the defaults
  const data = { ...defaults, ...edits };
  const changed = Object.keys(edits).length > 0;

  const update = (field) => (e) =>
    setEdits((prev) => ({ ...prev, [field]: e.target.value }));

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit(data);
      }}
    >
      <input value={data.name} onChange={update('name')} />
      <input value={data.email} onChange={update('email')} />
      <button type="submit" disabled={!changed}>
        Save
      </button>
    </form>
  );
}

With params = { the_name: 'Ada', email: 'ada@old.io', page: '2' } and the user editing only the email:

defaults // { name: 'Ada', email: 'ada@old.io', role: 'viewer' }
edits    // { email: 'ada@new.io' }
data     // { name: 'Ada', email: 'ada@new.io', role: 'viewer' }

page never reaches the form or the API, the fields the user didn’t touch keep following the params, and data is what fills the form and is sent via API.

Wrapping up#

Before writing a useEffect, ask yourself:

  • Is there an external system to sync with (network, timers, subscriptions, browser APIs)? If not, you probably don’t need an effect.
  • Can I write the cleanup? If not, question the effect.
  • Is it a reaction to a user action? Put it in the event handler, e.g. a POST.
  • Is the effect short? Store the raw data and derive everything else during render.
  • Is there a single source of truth? Don’t copy props or params into state: keep in state only what the user changed and compute the rest during render.