FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·102·13 MIN READ

102: Effects Deep Dive — Lifecycle, Dependencies, and Effect Events

TOPICS COVERED: Effects Deep Dive — Lifecycle, Dependencies, and Effect Events

Learning objectives

You will learn to:

  • model Effects as synchronization processes rather than as generic post-render code;
  • reason about setup and cleanup as props and state change across rerenders;
  • satisfy dependency rules without suppressing the linter;
  • recognize and remove Effects that only transform React state;
  • diagnose and fix stale closures;
  • use React 19.2 useEffectEvent for the case it is designed to solve;
  • separate event-driven work from synchronization work;
  • design subscriptions that remain correct when Strict Mode replays them;
  • investigate Effect loops and duplicate work systematically.

Effects synchronize external systems

The phrase “after render” is a tempting description of an Effect, but it is not a useful definition. An Effect exists to keep a React component synchronized with something outside React:

  • network connection;
  • browser event source;
  • timer;
  • media player;
  • third-party widget;
  • external store;
  • imperative DOM API.

For example, this Effect makes the connection match the current room:

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

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

The setup connects to the room represented by roomId. The returned cleanup undoes that setup when the room changes or the component is removed. Treat one Effect as one independent synchronization process; that model is more reliable than thinking of it as a convenient place to put any code that should run after rendering.

Lifecycle of one Effect

Suppose roomId changes from "general" to "support". React performs the transition in this order:

text
render with support
↓
commit
↓
cleanup synchronization for general
↓
setup synchronization for support

When the component is removed, React cleans up the current synchronization:

text
cleanup current synchronization

In development Strict Mode, React deliberately performs an extra setup → cleanup → setup cycle. This is a diagnostic: it checks whether setup has a complete inverse and exposes subscriptions, registrations, or other resources that leak. If the cycle breaks your logic, fix the Effect rather than disabling Strict Mode to hide the symptom.

Dependency arrays describe reactive inputs

jsx
useEffect(() => {
  const connection =
    createConnection(serverUrl, roomId);

  connection.connect();

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

The setup reads both serverUrl and roomId, and both values determine which external connection it creates. They are therefore reactive inputs to this synchronization. The dependency array is not a manually chosen schedule such as “run this twice” or “run this on mount.” It describes the values whose changes require React to replace the current synchronization.

Infinite loop example

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

This Effect creates a new object and stores it in state. That state update causes another render; the new object means options has changed, so the Effect runs again and creates another object. The dependency loop is a symptom of duplicated state, not a scheduling problem.

The usual fix is to derive the value during render:

jsx
const options = {
  sort,
  filter,
};

Do not patch this loop with an empty dependency array. That would hide the relationship between the inputs and the value, leaving the component with stale or inconsistent options.

You might not need an Effect

An Effect is for synchronization. If a value can be calculated from current props and state, or if work belongs directly to a user action, an Effect adds an unnecessary render and another lifecycle to reason about.

Derived data

Wrong:

jsx
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

Correct:

jsx
const fullName = `${firstName} ${lastName}`;

The second version cannot lag one render behind because it is calculated from the values used by that render. It also avoids state, an Effect, and the extra update required to keep duplicated state in sync.

Event-specific logic

Wrong:

jsx
useEffect(() => {
  if (submitted) {
    saveTask(task);
  }
}, [submitted, task]);

Correct:

jsx
async function handleSubmit() {
  await saveTask(task);
}

Saving is caused by the submit action, not by the component merely existing in a state where submitted is true. Let the event handler own the operation. This avoids accidental repeats when another render happens with the same flag.

Resetting state when identity changes

Sometimes a component should represent a new entity rather than manually resetting every piece of local state. A key gives React a clear identity boundary:

jsx
<Editor key={task.id} task={task} />

That is often clearer than:

jsx
useEffect(() => {
  setDraft(task.title);
}, [task.id, task.title]);

The key causes the Editor instance to be recreated for a different task, so its initial state belongs to the new identity.

Object and function dependencies

This Effect reruns on every render:

jsx
const options = {
  serverUrl,
  roomId,
};

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

The object literal creates a new object on every render. Even when serverUrl and roomId have not changed, the object reference has, so React sees a changed dependency.

Prefer constructing the object inside the Effect and listing the primitive reactive inputs:

jsx
useEffect(() => {
  const options = {
    serverUrl,
    roomId,
  };

  const connection = connect(options);

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

The same identity issue applies to functions created during render. Do not reach for useMemo or another identity workaround solely to appease the dependency rule unless memoization is actually the right model for the value and its cost.

Stale closures

Each render creates its own snapshot of props, state, and local variables. An asynchronous callback created by that render retains access to that snapshot.

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

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

Because the dependency array is empty, this interval is created from the initial render. It keeps reading the initial render's count, even after later renders display a different value.

Adding count to the dependencies makes the callback current by replacing the timer whenever count changes:

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

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

Sometimes recreating the timer is correct. Sometimes the external connection should live longer, while a callback invoked by that connection needs the latest value. Those are different lifecycles, and useEffectEvent can express the distinction.

React 19.2: useEffectEvent

useEffectEvent separates non-reactive, event-like logic from the synchronization that causes the event to be registered. Consider a chat connection:

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

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);

    connection.on('connected', () => {
      onConnected();
    });

    connection.connect();

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

Changing theme should change the notification styling, but it should not disconnect and reconnect the room. The connection lifecycle depends on roomId; the callback invoked when the connection reports connected needs the latest committed theme. onConnected provides that current value without making the room subscription depend on the theme.

Do not misuse Effect Events

The wrong mental model is:

I do not like this dependency, so I will hide it in useEffectEvent.

Effect Events are not an escape from reactivity or a general-purpose stable callback. Use one when the logic is genuinely an event fired from an Effect and should not control that Effect's synchronization lifecycle.

Subscription example

This hook subscribes to browser online/offline events and removes exactly those listeners during cleanup:

jsx
function useOnlineStatus() {
  const [online, setOnline] = useState(
    navigator.onLine,
  );

  useEffect(() => {
    function handleOnline() {
      setOnline(true);
    }

    function handleOffline() {
      setOnline(false);
    }

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener(
        'online',
        handleOnline,
      );
      window.removeEventListener(
        'offline',
        handleOffline,
      );
    };
  }, []);

  return online;
}

The empty dependency array is appropriate here because the subscription is to the stable window event source and the handler functions are created and removed within the same Effect execution. Strict Mode can replay this setup safely because cleanup removes both listeners. Later you will see useSyncExternalStore, which is a stronger primitive for subscribing to external stores.

Fetching in Effects

Manual fetching in an Effect is useful for learning the synchronization model. The request belongs to the current ownerId, and cleanup aborts work belonging to an obsolete render:

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

  async function load() {
    try {
      const response = await fetch(
        `/api/tasks?owner=${ownerId}`,
        { signal: controller.signal },
      );

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

      const data = await response.json();
      setTasks(data.tasks);
    } catch (error) {
      if (error.name !== 'AbortError') {
        setError(error);
      }
    }
  }

  load();

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

The response.ok check matters because fetch does not reject merely because the server returned an HTTP error status. The abort check prevents expected cancellation from being shown as an application error.

For production server-state, later lessons move this responsibility to route loaders or TanStack Query v5. Those systems address caching, deduplication, invalidation, retries, and ownership more completely than a collection of component Effects.

Effect decomposition

Avoid one giant Effect that happens to manage unrelated systems:

jsx
useEffect(() => {
  connectChat();
  document.title = title;
  startAnalytics();
  subscribeToResize();

  return () => {
    disconnectChat();
    stopAnalytics();
    unsubscribeResize();
  };
}, [roomId, title, userId]);

These operations have different triggers and different cleanup lifecycles. A title update should not force a chat reconnection, and a resize subscription should not be coupled to analytics. Prefer separate Effects with separate dependency lifecycles so each synchronization has one clear owner.

Debugging Effect loops

When an Effect loops or performs duplicate work, trace the state transition instead of changing dependencies at random:

  1. identify which setter runs inside the Effect;
  2. identify which dependency that setter changes;
  3. ask whether the state can be derived during render;
  4. move event-specific work to the event handler;
  5. move non-reactive object or function creation inside the Effect;
  6. avoid suppressing exhaustive-deps;
  7. confirm that cleanup fully reverses setup.

This process distinguishes a genuine external synchronization from duplicated state, unstable references, and incomplete cleanup.

Exercises

  1. Remove an Effect that calculates filtered tasks.
  2. Fix an interval that logs stale state.
  3. Refactor a reconnecting chat Effect with useEffectEvent.
  4. Build a window event subscription with correct cleanup.
  5. Add abort behavior to an Effect-based request.
  6. Split one large Effect into independent synchronization processes.

Exit questions

  1. Why is an Effect not just “after render”?
  2. What determines Effect dependencies?
  3. Why does Strict Mode rerun setup/cleanup in development?
  4. What is a stale closure?
  5. What problem does useEffectEvent solve?
  6. When should logic move from an Effect to an event handler or render?

Official references


Deep dive: dependency reasoning with real reactive values

The syntax of an Effect is usually straightforward. The harder engineering decision is identifying which values are reactive and deciding what the synchronization should actually depend on.

Consider:

jsx
function ChatRoom({ roomId, serverUrl }) {
  const [theme, setTheme] = useState('dark');

  useEffect(() => {
    const connection = createConnection({
      roomId,
      serverUrl,
    });

    connection.connect();

    connection.on('connected', () => {
      showNotification('Connected', theme);
    });

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

This reconnects whenever theme changes. That is mechanically consistent with the dependency list, but it is not the desired synchronization: the connection itself depends only on:

text
roomId
serverUrl

The notification needs the latest theme, while the network subscription should not be recreated for a visual preference. React 19.2 useEffectEvent makes those two lifetimes explicit:

jsx
const onConnected = useEffectEvent(() => {
  showNotification('Connected', theme);
});

useEffect(() => {
  const connection = createConnection({
    roomId,
    serverUrl,
  });

  connection.on('connected', onConnected);
  connection.connect();

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

Important caveat

Do not pass Effect Events to children:

jsx
<Child onConnected={onConnected} />

Effect Events are local to the component's Effects. They are not general stable callbacks intended for component APIs. Likewise, do not include an Effect Event in a dependency array.

Reactive versus non-reactive values

Within a component, props, state, and variables derived from them are reactive. For example:

jsx
const endpoint = `${baseUrl}/rooms/${roomId}`;

If an Effect reads endpoint, it indirectly depends on both baseUrl and roomId, because either input can change the endpoint.

A module constant is not reactive:

jsx
const API_VERSION = 'v2';

A setter returned by useState has stable identity. A ref object is also stable, although its .current property is mutable and is not itself a reactive dependency. Knowing these categories makes dependency arrays predictable instead of arbitrary-looking.

Removing a dependency by changing architecture

Sometimes the right way to remove a dependency is to change which part of the logic is reactive. Suppose:

jsx
function Product({ productId, cart }) {
  useEffect(() => {
    analytics.viewedProduct(productId, cart.length);
  }, [productId, cart]);
}

The requirement is:

Record a product view when productId changes, using the cart count at that moment.

The cart count is needed when the event fires, but cart changes should not reschedule the product-view synchronization. Use an Effect Event:

jsx
const onViewedProduct = useEffectEvent((id) => {
  analytics.viewedProduct(id, cart.length);
});

useEffect(() => {
  onViewedProduct(productId);
}, [productId]);

This is not hiding a dependency from the linter. It models an event within the synchronization lifecycle: productId controls when the event is emitted, while the event reads the latest cart value when it runs.

Cleanup correctness and idempotence

An Effect should tolerate this sequence:

text
setup
cleanup
setup
cleanup

For example, this setup leaks because cleanup does not undo the registration:

jsx
useEffect(() => {
  globalRegistry.push(id);

  return () => {
    // forgot to remove
  };
}, [id]);

Strict Mode exposes that leak during development. A symmetric implementation removes the same registration:

jsx
useEffect(() => {
  globalRegistry.add(id);

  return () => {
    globalRegistry.delete(id);
  };
}, [id]);

For third-party APIs, verify that destroy or unsubscribe methods are safe even if initialization was only partially completed. Cleanup should be safe to call for every setup that may have occurred.

Event listener identity

This works because the same handleResize function is used for adding and removing the listener within one Effect execution:

jsx
useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }

  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

This does not work:

jsx
window.addEventListener('resize', () => setWidth(window.innerWidth));

return () => {
  window.removeEventListener('resize', () => setWidth(window.innerWidth));
};

The two arrow expressions create different function objects. Removing the second function cannot remove the first listener, so each subscription leaks.

Subscription versus snapshot: when useSyncExternalStore is better

For a store that exists outside React, this hand-written subscription:

jsx
useEffect(() => {
  return store.subscribe(() => {
    setValue(store.getValue());
  });
}, []);

can be fragile under concurrent rendering and server rendering. React provides a dedicated protocol:

jsx
useSyncExternalStore(
  store.subscribe,
  store.getSnapshot,
  store.getServerSnapshot,
);

Use the dedicated primitive for external stores. Effects remain appropriate for many one-off browser subscriptions, but an external-state library should integrate through the store protocol so React can coordinate subscriptions and snapshots correctly.

Data fetching and Suspense boundaries

An Effect-based fetch:

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

starts after commit. Router loaders, query libraries, or framework data systems can often start work earlier, and Suspense-aware sources can place pending UI at a boundary.

The timing difference affects architecture:

text
Effect fetch
→ component renders loading
→ commit
→ request starts

versus:

text
route/query/framework
→ request may start before component commit
→ cache/dedupe/boundary logic

That is why production data architecture should not default to “fetch everything in Effects.” The point is not that an Effect fetch is invalid; it is that request ownership, caching, deduplication, and pending UI often belong to a higher-level data system.

Effect ordering

Different Effects in one component run in declaration order after commit, but correctness should not depend on incidental ordering between unrelated Effects. For example, avoid this when it represents one logical workflow:

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

useEffect(() => {
  if (ready) {
    startSomething();
  }
}, [ready]);

Either give the event or synchronization one clear owner, or model the state transitions deliberately. Splitting code into Effects is not automatically decomposition if one Effect only exists to trigger the next.

Async Effect callback trap

Do not write:

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

An Effect callback may return a cleanup function, not a Promise. An async function always returns a Promise, so React cannot interpret that return value as cleanup.

Instead, keep the Effect callback synchronous and define the asynchronous operation inside it:

jsx
useEffect(() => {
  let ignore = false;

  async function load() {
    const data = await getData();

    if (!ignore) {
      setData(data);
    }
  }

  load();

  return () => {
    ignore = true;
  };
}, []);

The flag prevents an old request from committing its result after cleanup. Prefer AbortController where the API supports cancellation because cancellation stops the work rather than merely ignoring the result.

Failure clinic: fake "mount" semantics

Legacy thinking often starts with the question “How do I run this on mount?”:

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

That question focuses on a component lifecycle event instead of the external system. Modern reasoning asks:

What external system is this component synchronizing with for its lifetime?

If initialization belongs to the app or module rather than to one component instance, move it out of the component lifecycle. If it belongs to this component, identify its setup, reactive inputs, and cleanup rather than relying on an empty array as a mount-only command.

Debugging checklist

For any Effect, write down:

text
External system:
Reactive inputs:
Setup:
Cleanup:
Why this cannot happen during render:
Why this is not an event handler:
Why a router/query/store is not the better owner:

If you cannot fill this in, the Effect probably needs redesign. In particular, a missing external system usually means the code is derived data or event-specific work in the wrong place.

Deep-dive exercises

  1. Fix a theme-triggered chat reconnection using useEffectEvent.
  2. Demonstrate Strict Mode revealing missing cleanup.
  3. Rewrite an anonymous event-listener cleanup bug.
  4. Convert an async Effect callback into a correct internal async function.
  5. Replace a hand-written external store subscription with useSyncExternalStore.
  6. Compare request start timing for Effect fetch versus route/query loading.

Mastery check

Explain:

  • reactive values;
  • Effect Events;
  • cleanup symmetry;
  • why external stores have a dedicated Hook;
  • why async Effect callbacks cannot directly return Promises;
  • how architecture can remove dependencies instead of suppressing them.

Production case study: WebSocket room with stable connection and current UI callbacks

Requirements:

text
connect when roomId changes
disconnect when leaving
show incoming message using latest muted/user settings
do not reconnect when theme changes

Architecture:

jsx
function useRoom({ roomId, onMessage }) {
  const handleMessage = useEffectEvent(onMessage);

  useEffect(() => {
    const socket = connectToRoom(roomId);

    socket.on('message', (message) => {
      handleMessage(message);
    });

    return () => {
      socket.close();
    };
  }, [roomId]);
}

Consumer:

jsx
useRoom({
  roomId,
  onMessage(message) {
    if (!muted) {
      addToast({
        message: message.text,
        tone: theme === 'dark' ? 'light' : 'dark',
      });
    }

    queryClient.setQueryData(
      ['room', roomId, 'messages'],
      (current) => appendMessage(current, message),
    );
  },
});

The connection lifetime depends on roomId. The message callback sees the latest muted, theme, and query-client state without reconnecting whenever those values change. That is the practical value of separating the transport subscription from the callback logic it invokes.

Realtime cache ownership

Do not copy messages from Query into component state merely because a WebSocket updates them. If Query owns server messages, realtime events can update or invalidate that cache.

This is a useful production pattern:

text
HTTP/query loads baseline
WebSocket/SSE sends changes
query cache remains server-state owner

The socket is transport, not a second source of truth. Keeping one owner prevents the cache and component state from drifting apart.


Additional depth: Effect review checklist for code reviews

For every new Effect in a pull request, reviewers can ask:

  1. External system: what outside React is synchronized?
  2. Reactive inputs: which props or state determine that synchronization?
  3. Cleanup: what undoes setup?
  4. Concurrency: can old asynchronous work finish after new work begins?
  5. Server rendering: what happens before Effects exist?
  6. Strict Mode: does setup and cleanup remain correct when replayed?
  7. Alternative owner: should Router, Query, CSS, an event handler, or derived render own this instead?
  8. Dependency rule: are any linter warnings suppressed?
  9. Effect Event: is some logic event-like and non-reactive?
  10. Test: how will cleanup and race behavior be verified?

Example review

jsx
useEffect(() => {
  setFiltered(tasks.filter((task) => task.title.includes(query)));
}, [tasks, query]);

Answers:

text
External system: none
Cleanup: none
Alternative owner: render

So remove it:

jsx
const filtered = tasks.filter((task) =>
  task.title.includes(query),
);

There is no external system to synchronize here. The filtered list is derived data, so render is the correct owner.

Another review

jsx
useEffect(() => {
  const subscription = analytics.subscribe(userId, handleEvent);
  return () => subscription.unsubscribe();
}, [userId, handleEvent]);

Now there is an external system. The next question is whether changing handleEvent should resubscribe, or whether the callback is event-like and should become an Effect Event. The answer depends on the intended subscription lifetime, not on a desire to silence the linter.

This review discipline keeps Effects from becoming a generic place for “logic that was hard to place.”

Reader page: /react/lesson/102/effects-deep-dive-lifecycle-dependencies-and-effect-events