Stockio

useEffect Cleanup and Dependencies: Fix Loops in React

Learn when React effects are necessary, how dependency arrays work, and how to prevent stale closures, duplicate subscriptions, and runaway render loops.

By Sofia Marchetti

JavaScript Engineer ·

On this page

Effects are often blamed for unpredictable React behavior when the real problem is unclear ownership. A component calculates what to render; an effect synchronizes that component with something outside React. Mixing those responsibilities produces dependency warnings, repeated requests, and state that briefly contradicts the interface.

Start with the external system

Before writing an effect, name the system it synchronizes: a browser event, a chat connection, a timer, or an imperative widget. If you cannot name one, calculate the value during render or perform the operation in an event handler. Derived state rarely needs its own effect.

For example, filtering a product list from props belongs in render. Saving a product after clicking Save belongs in that click handler. Connecting to a room because the selected room changed is synchronization and usually belongs in an effect. This distinction removes dependencies rather than hiding them.

When debugging a subscription payload, paste a sanitized example into the JSON Formatter. Compare the actual fields with the values your effect reads. It helps expose a nested room identifier or changing options object that was overlooked during implementation. Formatting does not establish runtime validity or make private data safe to share.

Treat dependencies as a description

An effect's dependency array describes the reactive values it reads. It is not a scheduling menu. Props, state, and variables declared inside the component are reactive; React compares listed dependencies with their previous values using Object.is. Newly created objects and functions therefore count as changed even when their contents look identical.

The React effect reference explains this contract. Dependency completeness prevents stale closures: a callback created during one render otherwise keeps reading that render's values. Suppressing the linter replaces an explicit warning with a timing-dependent defect.

Move an object inside the effect when only that effect needs it. Move constants outside the component when they truly do not depend on rendering. If a calculation can accept a primitive identifier instead of an entire object, use that narrower boundary. Do not add memoization simply to silence a dependency warning before understanding why the object exists.

Suppose a room selector receives a new room object after every parent render. Depending on room.id avoids reconnecting merely because the wrapper object changed. That is correct only if the connection actually depends on the identifier alone. If region or credentials also affect it, those dependencies must remain visible.

Make setup and cleanup symmetrical

A good effect can survive setup, cleanup, then setup again without leaving duplicate work. Development Strict Mode deliberately exercises an extra setup-and-cleanup cycle. It is a diagnostic, not evidence that production always runs effects twice. A ref that blocks the second setup usually hides missing cleanup.

import { useEffect, useState } from 'react';

export function WindowWidth() {
const [width, setWidth] = useState<number | null>(null);

useEffect(() => {
  const update = () => setWidth(window.innerWidth);
  update();
  window.addEventListener('resize', update);
  return () => window.removeEventListener('resize', update);
}, []);

return <output>{width === null ? 'Measuring' : width}</output>;
}

Here cleanup symmetry means removing exactly the listener that setup added. Creating a different arrow function in removeEventListener would not work. Timers similarly need their corresponding clear operation; connections need disconnect; observers need disconnect or unobserve according to their lifetime.

The initial null also matters for server rendering: the first render does not access window. Effects run on the client, after rendering, rather than on the server. A CSS solution is preferable if the only goal is adapting layout to viewport width; measuring state should support actual application behavior, not duplicate media queries.

Separate events from synchronization

A purchase request is caused by a user action, not by the fact that a receipt component mounted. Put it in a submit handler. Otherwise revisiting a route, restoring UI, or remounting a subtree can repeat a mutation. User intent belongs at the event boundary, with duplicate protection where the operation requires it.

A subscription is different: its lifetime follows a rendered identity. If accountId changes, cleanup should release the old subscription before the new one becomes authoritative. Keep unrelated synchronization processes in separate effects so changing a document title does not restart a socket.

For asynchronous work, cancellation and stale-result protection solve related but distinct problems. An AbortController can ask fetch to stop; a cleanup flag can stop an obsolete result from updating state. Neither substitutes for a route data layer when caching, deduplication, and navigation are requirements. See MDN's AbortController documentation for the browser contract.

When a state update depends on previous state, use a functional updater. A timer can call setCount(value => value + 1) without reading count from its closure. That removes a genuine dependency rather than pretending it does not exist. Read React's guidance on unnecessary effects before extracting a reusable hook around a questionable pattern.

Debug lifetimes rather than render counts

Log a stable resource identifier at setup and cleanup. Observe whether the same subscription survives too long or whether a changed dependency legitimately restarts it. Counting console messages without identifying their phase makes Strict Mode, parent renders, and effect runs look like one issue.

Use resource ownership as a review question: which component creates the resource, and what event ends its lifetime? Route transitions, key changes, and conditional rendering can all unmount a component. A correct implementation handles those transitions without relying on a specific navigation order.

Add tests that mount, change the relevant identifier, and unmount. Assert that old listeners are removed and old results cannot replace current ones. Test rapid A-to-B-to-A changes, not only one successful mount. For timers, advance a controlled clock and verify cleanup leaves no pending callbacks.

Keep a short reproduction with one parent and one child before changing application architecture. The development collection provides broader implementation references, while the React and frameworks collection keeps related component patterns together. Small reproductions make identity changes much easier to see than a dashboard full of unrelated updates.

Finally, avoid promising an effect will execute exactly once. An empty array means it reads no reactive dependencies, not that the component has a permanent global lifetime. Repeatable setup is the durable design constraint.

Key takeaways

  • Use effects to synchronize external systems, not to calculate ordinary render values.
  • Include every reactive dependency and simplify the code when identities change unnecessarily.
  • Pair setup with cleanup and test remounts, identifier changes, and stale asynchronous results.
  • Keep user-triggered mutations in event handlers and give shared server data an appropriate data layer.
Featured sponsor

Reach designers & developers at work

Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.

Become a sponsor

Keep reading

Related articles

More in JavaScript & Frameworks
JavaScript & Frameworks

10 Best JavaScript Charting Libraries

Compare ten JavaScript chart libraries for dashboards, reports, and interactive data stories. See where each excels, what it costs, and how to choose based on your app and team.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

8 Best React State Management Libraries

Find a React state approach that fits your app, from built-in hooks to dedicated stores, with clear trade-offs around data flow, debugging, async work, and learning effort.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

12 Best and Most Popular JavaScript Libraries

Compare twelve widely used JavaScript libraries for interfaces, data, testing, and everyday development, with clear strengths, trade-offs, and advice for choosing only what your project needs.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

localStorage vs sessionStorage vs IndexedDB Compared

Choose browser storage by data lifetime, size and access pattern. Compare synchronous Web Storage with IndexedDB transactions, migrations, quotas and failure handling.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

React Server Components Explained for Developers

Learn what React Server Components execute, what crosses the client boundary, and how to structure data access, interactive widgets, and mutations without leaking private data.

Includes: JSON formatterDaniel KimDaniel Kim
JavaScript & Frameworks

useMemo vs useCallback vs React.memo: Fewer Rerenders

Learn when React memoization helps, how prop identity affects rerenders, and why profiling and state placement should come before useMemo and useCallback.

Includes: JSON formatterDaniel KimDaniel Kim