Auditing useEffect: Ten Common Bugs, Their Symptoms and Real Fixes
Recognize the ten most common useEffect mistakes in React, from missing cleanup and stale closures to race conditions and prop syncing, and apply the right fix.
This article is published in English.
Sources: 10 React useEffect Mistakes That Are Secretly Breaking Your App in 2026
React 19, the React Compiler and Server Components have moved a lot of work out of useEffect, yet the hook is still where many of the hardest React bugs live: leaks, stale data, flicker and render loops. Most come from ten recurring mistakes. Below, each one gets the broken version, the symptom and a fix, followed by a checklist for auditing your own effects.
The principle behind every fix: useEffect exists to synchronize a component with something outside React, such as a timer, socket or browser API.
1. Forgetting the cleanup function
Timers, subscriptions and event listeners created in an effect keep running after the component re-renders or unmounts unless you tear them down. Here the interval is never cleared:
// ❌ Broken: No cleanup
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// Missing: return () => clearInterval(timer);
}, []);
The symptoms are leaked intervals and duplicated listeners. In development, Strict Mode deliberately mounts, unmounts and remounts components to surface this: without cleanup you get two intervals and the counter advances twice per second. In production the leak appears whenever the component remounts. Return a function that undoes the setup:
// ✅ Fixed: Always clean up
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
2. Using an empty dependency array to mean "run once"
An empty array does not mean "on mount" in any special sense. It tells React the effect depends on nothing, so it never re-runs. If the effect reads props or state, those values are frozen at their first-render values:
// ❌ Broken: Stale closure
function ChatRoom({ roomId }) {
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, []); // roomId changes, but effect never re-runs
}
When roomId changes, the component stays in the original room. List every reactive value the effect uses; the react-hooks/exhaustive-deps lint rule catches this.
// ✅ Fixed: Include real dependencies
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, [roomId]);
Now React disconnects from the previous room and connects to the new one each time roomId changes.
3. Depending on objects that are recreated each render
Dependencies are compared by reference. An object or array literal created during render is a new reference every time, so an effect that depends on it re-runs after every single render:
// ❌ Broken: New object every render
function Parent() {
const config = { timeout: 5000 }; // New reference every time
useEffect(() => {
fetchData(config);
}, [config]); // Runs every single render
}
Stabilize the reference. If the value never changes, define it once outside the component. If it is built from props or state, wrap it in useMemo with the right dependencies. The snippet below shows both options side by side; in practice you would choose one:
// ✅ Fixed: Stable reference
const CONFIG = { timeout: 5000 }; // Defined outside component
function Parent() {
const config = useMemo(() => ({ timeout: 5000 }), []);
useEffect(() => {
fetchData(config);
}, [config]);
}
Often simpler still: depend on primitives (timeout rather than config) and build the object inside the effect. The React Compiler memoizes many values in code it compiles, but not objects arriving from uncompiled code or libraries, so do not rely on it for effect correctness.
4. Letting async responses race each other
When a dependency changes quickly, as with a search box or fast tab switching, several requests can be in flight at once, and they do not necessarily resolve in order:
// ❌ Broken: Race condition
useEffect(() => {
fetch(`/api/search?q=${query}`).then(res => res.json()).then(setResults);
}, [query]);
If a user types "react" and then "react hooks", the response for "react" may arrive last and overwrite the correct results. The fix is to cancel the outdated request in the cleanup. AbortController aborts the fetch, and the resulting AbortError is ignored so it is not reported as a real failure:
// ✅ Fixed: Abort in-flight requests
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [query]);
For search interfaces specifically, there are further subtleties, covered in fixing race conditions that debouncing cannot solve.
5. Storing derived values in state
If a value can be computed from existing props or state, it does not need its own state or an effect:
// ❌ Broken: Redundant state + effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Each change renders twice, first with a stale fullName, and you now have two sources of truth. Compute the value during render, using useMemo only for genuinely expensive work:
// ✅ Fixed: Compute during render
const fullName = `${firstName} ${lastName}`;// Or for expensive computations
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
Note that the snippet shows two alternatives; declaring fullName twice in the same scope is a syntax error, so keep only one. The broader pattern is explored in a safer alternative to syncing state with useEffect.
6. Creating an update loop
An effect that sets state it also depends on schedules itself forever:
// ❌ Broken: Infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]);
The update triggers a render, the render changes count, the changed count re-runs the effect, and so on. React eventually stops it with a "Maximum update depth exceeded" error. A functional update removes the need to read count inside the effect, so it no longer needs to be a dependency:
// ✅ Fixed: Functional update + correct dependencies
useEffect(() => {
const timer = setTimeout(() => {
setCount(c => c + 1); // Functional update avoids dependency
}, 1000);
return () => clearTimeout(timer);
}, []); // No count dependency needed
Note that this changes behavior: the counter now increments once, a second after mount. For a ticking counter, use setInterval with cleanup as in mistake 1. Any setState in an effect needs a condition that guarantees it settles.
7. Omitting the dependency array entirely
Without a second argument, an effect runs after every render. That is occasionally what you want, but usually it is accidental and expensive:
// ❌ Broken: Runs on every render
useEffect(() => {
console.log('Effect running');
performHeavyComputation(data);
});
The React Compiler reduces unnecessary re-renders, but it does not make the work inside your effects cheaper, so an unguarded effect doing heavy work remains a bottleneck. Declare the actual dependency:
// ✅ Fixed: Explicit dependencies
useEffect(() => {
performHeavyComputation(data);
}, [data]);
8. Handling user events in effects
Effects run because something rendered, not because a user did something. Analytics tied to a state flag fire whenever that flag changes, for any reason:
// ❌ Broken: Event logic in effect
useEffect(() => {
if (isModalOpen) {
trackEvent('modal_opened');
}
}, [isModalOpen]);
This event is logged on mount if the modal starts open, when a parent changes the prop, or when state is restored, not only when a user clicks. Put interaction logic in the handler that performs the interaction:
// ✅ Fixed: Event handler
function handleOpenModal() {
setIsModalOpen(true);
trackEvent('modal_opened');
}
If you can name the user action that should trigger the code, it belongs in a handler.
9. Fetching data with a bare effect
With Server Components and framework loaders handling initial data in many apps, client-side fetching in effects is needed less often. When you do fetch on the client, a raw effect is fragile:
// ❌ Broken: Raw fetch in effect
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
It has no loading state, no error handling, no request deduplication, no caching and, as mistake 4 shows, no protection against races. A data-fetching library handles all of that:
// ✅ Fixed: Use a data-fetching library
const { data: user, isLoading, error } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000,
});
TanStack Query and SWR are the usual choices. Without them, write one custom hook with loading, error and abort handling instead of ad-hoc fetch effects.
10. Copying props into state and syncing them
A component that receives an initial value, copies it into state and then uses an effect to mirror prop changes is fighting itself:
// ❌ Broken: Prop-to-state sync
function EmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail);
useEffect(() => {
setEmail(initialEmail); // Sync prop to state
}, [initialEmail]);
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
Whenever the parent supplies a new initialEmail, the user's typing is silently overwritten, after an extra render with the old value. Pick one owner: make the component fully controlled, or keep it uncontrolled and reset it deliberately by changing its key, which gives React a fresh instance with fresh state:
// ✅ Fixed: Controlled or fully uncontrolled
// Option 1: Fully controlled — no internal state
function EmailInput({ email, onChange }) {
return <input value={email} onChange={onChange} />;
}
// Option 2: Fully uncontrolled with key
function EmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail);
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
// Usage: Reset by changing key
<EmailInput key={userId} initialEmail={user.email} />
How React 19 changes the picture
Recent React versions make these mistakes both more visible and less necessary:
- Strict Mode's development-only double mounting of effects exposes missing or incorrect cleanup early.
- The React Compiler memoizes components and many values automatically, which reduces accidental re-renders but does not fix effect logic.
- Server Components take over much of initial data loading, leaving effects mainly for client-only work such as subscriptions, animations and browser APIs.
- The
useAPI can read promises and context during render, removing some reasons to reach for effects around asynchronous values.
A pre-merge checklist for effects
Before an effect ships, check:
- Every subscription, timer and listener has a matching cleanup.
- The dependency array lists every reactive value the effect reads.
- Object and array dependencies are stable, or replaced by primitive dependencies.
- Async work is cancelled or ignored when dependencies change.
- Nothing computed in the effect could simply be derived during render.
- No state update can re-trigger the same effect without a stopping condition.
- User-triggered logic lives in event handlers.
- Data fetching uses a server component, a framework loader or a data-fetching library where possible.
- Each piece of state has exactly one source of truth.
Wrapping up
Nearly every useEffect bug comes from using the hook for something other than external synchronization. Derived values belong in render, user actions in handlers, server data in a data layer, and resettable state behind a key. The genuine subscriptions that remain need accurate dependencies and cleanup. Often the best result of an audit is a deleted effect.