DEV_NET_CORE
GET_STARTED
ReactHooks, effects, and custom hooks

Proper useEffect usage and cleanup

Overview

useEffect is a React Hook for synchronizing a component with an external system after React renders and commits the UI. External systems include browser APIs, timers, subscriptions, network connections, third-party widgets, analytics, and imperative libraries.

Code
function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

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

  return <h1>Room {roomId}</h1>;
}

The cleanup function undoes the setup. It runs before the effect is re-run with changed dependencies and when the component unmounts. In development Strict Mode, React may run setup, cleanup, and setup again to reveal missing cleanup bugs.

This topic matters in interviews because many React bugs come from using effects for the wrong job, missing dependencies, causing infinite loops, leaking subscriptions, failing to cancel stale async work, or writing cleanup that does not mirror setup.

The practical goal is to use effects only when needed, make dependencies honest, and write setup/cleanup code that remains correct across mount, update, unmount, and development re-checks.

Core Concepts

What useEffect Is For

useEffect is for synchronizing with systems outside React's render calculation.

Good effect use cases:

  • Connecting to a WebSocket or chat server.
  • Subscribing to browser events.
  • Starting and clearing timers.
  • Controlling a non-React widget.
  • Fetching data in simple client-side components.
  • Reporting analytics after a screen appears.
  • Synchronizing with APIs such as localStorage, when appropriate.

Example:

Code
function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

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

    window.addEventListener("resize", handleResize);

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

  return <p>{width}px</p>;
}

What useEffect Is Not For

Do not use effects for values that can be calculated during render.

Bad:

Code
const [fullName, setFullName] = useState("");

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

Good:

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

Do not use effects to handle user events that already have event handlers.

Bad:

Code
useEffect(() => {
  if (isSubmitted) {
    submitForm();
  }
}, [isSubmitted]);

Good:

Code
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
  submitForm();
}

Effects are escape hatches. If no external system is involved, you often do not need an effect.

Basic Effect Shape

An effect has setup logic and optional cleanup logic.

Code
useEffect(() => {
  setup();

  return () => {
    cleanup();
  };
}, [dependencies]);

The dependency array tells React which reactive values the setup uses. Reactive values include props, state, and variables or functions declared inside the component.

Code
function ChatRoom({ roomId, serverUrl }: Props) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();

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

If serverUrl or roomId changes, React cleans up the old connection and sets up the new one.

Effect Timing

Effects run after React commits the render to the DOM. That means effects do not block the render calculation.

This is why effects are suitable for synchronization after the UI has updated:

Code
useEffect(() => {
  document.title = `Inbox (${unreadCount})`;
}, [unreadCount]);

For visual work that must happen before the browser paints, such as measuring layout to avoid flicker, useLayoutEffect may be more appropriate. Most effects should use useEffect.

Effects do not run during server rendering. Code inside an effect is client-side behavior.

Cleanup

Cleanup is the function returned from an effect. It should undo whatever setup did.

Code
useEffect(() => {
  const id = window.setInterval(() => {
    setTime(new Date());
  }, 1000);

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

Common cleanup tasks:

  • Remove event listeners.
  • Clear timers.
  • Disconnect sockets.
  • Unsubscribe from stores.
  • Abort fetch requests or ignore stale results.
  • Destroy third-party widgets.

Cleanup must match setup. If setup subscribes, cleanup unsubscribes. If setup starts a timer, cleanup clears it.

When Cleanup Runs

Cleanup runs:

  • Before the effect runs again because dependencies changed.
  • When the component unmounts.
  • During development Strict Mode checks, after the first setup and before the second setup.

Example timeline:

Code
useEffect(() => {
  connect(roomId);

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

If roomId changes from "general" to "music":

  • Cleanup disconnects "general".
  • Setup connects "music".

This keeps the component synchronized with the latest props and state.

Strict Mode Double Setup

In development, Strict Mode may run an extra setup-cleanup-setup cycle. This is not a production behavior, but it exposes effects that are missing cleanup.

If this effect has no cleanup:

Code
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
}, [roomId]);

development may show duplicate connections.

Fix:

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

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

The goal is not to prevent the development re-check. The goal is to make the effect resilient to setup and cleanup happening multiple times.

Dependency Arrays

The dependency array should include every reactive value used by the effect.

Code
function ChatRoom({ roomId }: { roomId: string }) {
  const [serverUrl, setServerUrl] = useState("https://localhost:1234");

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

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

Do not remove dependencies just to silence the linter. Missing dependencies create stale closure bugs, where an effect reads old props or state.

If a dependency changes too often, restructure the code:

  • Move object creation inside the effect.
  • Move functions inside the effect.
  • Use primitive dependencies.
  • Use useCallback only when stable function identity is actually needed.
  • Split effects by responsibility.

No Dependency Array vs Empty Dependency Array

No dependency array:

Code
useEffect(() => {
  console.log("Runs after every render");
});

Empty dependency array:

Code
useEffect(() => {
  console.log("Runs after mount");
}, []);

Specific dependencies:

Code
useEffect(() => {
  console.log("Runs when roomId changes");
}, [roomId]);

An empty dependency array does not mean "ignore dependencies." It means the effect uses no reactive values from the component. If it does use props or state, the dependency list should include them.

Avoiding Object and Function Dependency Loops

Objects and functions created during render have new identity each render.

Problem:

Code
function ChatRoom({ roomId }: { roomId: string }) {
  const options = {
    serverUrl: "https://localhost:1234",
    roomId,
  };

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

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

options is new every render, so the effect runs too often.

Better:

Code
function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const options = {
      serverUrl: "https://localhost:1234",
      roomId,
    };

    const connection = createConnection(options);
    connection.connect();

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

Move object creation inside the effect so dependencies represent real reactive inputs.

Fetching Data in Effects

Effects can fetch data in simple client-side components, but you must handle stale results.

Code
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

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

    async function loadUser() {
      setUser(null);
      const nextUser = await fetchUser(userId);

      if (!ignore) {
        setUser(nextUser);
      }
    }

    loadUser();

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

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}

The cleanup prevents an older request from updating state after userId changes or the component unmounts.

For production apps, framework data loading, route loaders, server components, or client data libraries often handle caching, deduplication, race conditions, and loading states better than manual effects.

AbortController

When using fetch, cleanup can abort an in-flight request.

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

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

    const data = await response.json();
    setUser(data);
  }

  load().catch((error) => {
    if (error.name !== "AbortError") {
      setError("Failed to load user");
    }
  });

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

Aborting avoids unnecessary network work and prevents stale work from continuing after cleanup.

Subscriptions and External Stores

Subscriptions need cleanup.

Code
useEffect(() => {
  function handleChange() {
    setSnapshot(store.getSnapshot());
  }

  const unsubscribe = store.subscribe(handleChange);

  return () => {
    unsubscribe();
  };
}, [store]);

For external stores, React provides useSyncExternalStore, which is often better than hand-rolled subscription effects because it is designed for concurrent rendering and consistent snapshots.

Splitting Effects

Each effect should usually represent one synchronization process.

Bad:

Code
useEffect(() => {
  document.title = title;
  const connection = createConnection(roomId);
  connection.connect();

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

Better:

Code
useEffect(() => {
  document.title = title;
}, [title]);

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

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

Splitting effects makes dependencies smaller and behavior easier to reason about.

Infinite Effect Loops

An infinite effect loop usually happens when:

  • The effect updates state.
  • That state update changes a dependency.
  • The changed dependency causes the effect to run again.

Example:

Code
useEffect(() => {
  setOptions({ sort: "name" });
}, [options]);

Every setOptions creates a new object, which changes options, which runs the effect again.

Fix the data flow:

  • Derive the value during render if possible.
  • Initialize state directly.
  • Remove unnecessary object dependencies.
  • Use functional updates carefully.
  • Split the effect or remove it.

Reading Latest Values Without Re-running Effects

Sometimes an effect needs latest data for an event or callback, but that data should not cause the subscription itself to restart. Common solutions include:

  • Move non-reactive event logic into the event handler.
  • Store mutable latest values in a ref when appropriate.
  • Use React's newer effect-event patterns where available in your environment.
  • Split the effect so the subscription and state reaction are separate.

Use this carefully. Do not hide real dependencies. If the effect's synchronization depends on a value, include it.

Common Mistakes

Common mistakes include:

  • Using effects for derived state.
  • Using effects for user event logic.
  • Missing dependencies.
  • Adding empty dependency arrays while reading props or state.
  • Creating object or function dependencies that change every render.
  • Forgetting cleanup for subscriptions, timers, and connections.
  • Treating Strict Mode's development re-check as the bug instead of fixing cleanup.
  • Fetching data without handling stale responses.
  • Combining unrelated synchronization processes in one effect.
  • Updating state in an effect after every render and causing loops.

Best Practices

Use these rules of thumb:

  • Start by asking whether an external system is involved.
  • If not, you probably do not need an effect.
  • Keep render calculations in render.
  • Keep user-triggered actions in event handlers.
  • Include all reactive dependencies.
  • Let the linter help with dependency correctness.
  • Make cleanup mirror setup.
  • Split effects by synchronization purpose.
  • Handle stale async work with cleanup, ignore flags, or aborts.
  • Treat effects as synchronization, not lifecycle methods.

Interview Practice

PreviousMemoization with useMemo and dependency correctnessNext UpuseState, useReducer, useContext, and custom hooks