Treat an Effect as synchronization with something outside React. Calculate derived UI data during rendering, keep user actions in handlers, and make each external setup safe to clean up and restart.
Find the feedback path in a broken preview
products contains objects with numeric prices. Each render creates options; the Effect filters products into a fresh array and stores it in visible. This triggers a render with another options object, restarting the Effect. Object and array identities change even when products and prices stay equal.
Draw that chain before editing. An unstable dependency alone may cause extra synchronization without a loop; the state write completes this feedback path. Stop the broken example after observing it so it does not keep consuming resources.
'use client';
import { useEffect, useState } from 'react';
export default function BrokenPreview({ products }) {
const [visible, setVisible] = useState([]);
const options = { minimumPrice: 20 };
useEffect(() => {
setVisible(products.filter(
product => product.price >= options.minimumPrice
));
}, [products, options]);
return <p>{visible.length} products</p>;
}Read the lifecycle and dependency list accurately
Effect setup runs after a component commits. When a dependency changes, React runs the previous cleanup before the next setup; removing the component also runs cleanup. Dependency entries are compared with Object.is. Omitting the dependency array requests another run after each commit; an empty array still permits setup on a new mount. Neither spelling is an instruction to run exactly once forever.
Compare dependencies across committed renders: equal primitive prices can coexist with newly allocated options. List every state setter reachable from the Effect, including asynchronous callbacks and subscription handlers. Trace which updates influence a dependency, then check that explanation against the next render.
Fix the preview by calculating derived data during rendering
products and minimumPrice fully determine the qualifying list. Remove the Effect and extra visible state: each render calculates the correct list directly. Changing the price updates one state value, and the count follows from that input.
Try three products priced 10, 20 and 30. The initial minimum of 20 should show two products; changing it to 25 should show one; replacing the products prop should update the count as well. An empty dependency array in the broken component would fail that last requirement. If filtering is demonstrably expensive, consider caching the pure calculation, but useMemo is a performance choice, not a substitute for deciding what belongs in state.
'use client';
import { useState } from 'react';
export default function ProductPreview({ products }) {
const [minimumPrice, setMinimumPrice] = useState(20);
const visible = products.filter(
product => product.price >= minimumPrice
);
return (
<section>
<label>
Minimum price
<input type="number" value={minimumPrice}
onChange={event => setMinimumPrice(
Number(event.target.value)
)} />
</label>
<p>{visible.length} products</p>
</section>
);
}For a real subscription, make setup and cleanup match
This hook subscribes to the browser's online and offline events and removes the same callbacks during cleanup. update is created inside the Effect, so renders do not introduce a changing function dependency. Initially the hook returns true, then reads the browser's value after setup.
navigator.onLine is a connectivity hint, not proof that your API is reachable. Check actual request failures separately. In this example, no changing prop or state is read by setup, so the empty array describes the code. If a real subscription uses a room ID or URL, include those reactive inputs and create its options object inside setup. Do not hide an input from the list just to reduce runs.
'use client';
import { useEffect, useState } from 'react';
export function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
function update() {
setOnline(navigator.onLine);
}
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return online;
}Keep an explicit user action in its event handler
Copy belongs to the button press, not a text change or mount. This handler performs the operation and updates its status. Use it on a secure page with Clipboard API support; unavailable or denied operations show failed. Adapt status labels to your interface.
The same decision applies to saving a draft or submitting a form: ask which user action owns the operation. Moving a request into an Effect watching a submitted flag obscures that cause and creates another state transition to debug. For mutations, still handle retries and duplicate submissions at the application and server levels. Choosing a handler does not create an exactly-once guarantee.
'use client';
import { useState } from 'react';
export default function CopyButton({ text }) {
const [status, setStatus] = useState('idle');
async function handleCopy() {
setStatus('copying');
try {
await navigator.clipboard.writeText(text);
setStatus('copied');
} catch {
setStatus('failed');
}
}
return (
<>
<button disabled={status === 'copying'}
onClick={handleCopy}>Copy</button>
<span role="status">{status}</span>
</>
);
}Guard asynchronous responses when the requested ID changes
A different failure appears when the user opens article a and quickly switches to b: a's slower response can overwrite b. The hook below assumes a nonempty articleId and an endpoint at /api/articles/:id returning article JSON. Implement that endpoint or adapt the URL to your application. The active flag belongs to one setup; cleanup marks it inactive and aborts that setup's fetch. Both success and error updates are guarded.
The stored result is tagged with its requested ID. When a new ID renders before its Effect begins, the hook returns loading instead of briefly exposing the previous article. The outer Effect stays synchronous and returns cleanup; the asynchronous work lives inside load. Abort reduces unnecessary work, while the active check prevents an obsolete setup from publishing its result. Canceling a request does not undo work already performed by a server.
Test delayed responses: request a, switch to b, resolve b then a; b must remain visible. Check non-2xx responses and unmounting while pending. Production framework loading and caching facilities may additionally address deduplication, retries and server rendering.
'use client';
import { useEffect, useState } from 'react';
export function useArticle(articleId) {
const [result, setResult] = useState({
id: null, data: null, error: null
});
useEffect(() => {
let active = true;
const controller = new AbortController();
async function load() {
try {
const response = await fetch(
'/api/articles/' + encodeURIComponent(articleId),
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const data = await response.json();
if (active) {
setResult({ id: articleId, data, error: null });
}
} catch (error) {
if (active) {
setResult({ id: articleId, data: null, error });
}
}
}
load();
return () => {
active = false;
controller.abort();
};
}, [articleId]);
if (result.id !== articleId) {
return { status: 'loading', data: null, error: null };
}
return {
status: result.error ? 'error' : 'ready',
data: result.data,
error: result.error
};
}Distinguish a development check from an infinite loop
With Strict Mode enabled at the app root, development includes an extra initial setup-and-cleanup check before setup runs again. A bounded setup, cleanup, setup sequence is different from an unending sequence driven by changing dependencies. Current React documentation also distinguishes Strict Mode around only a subtree: it does not perform that extra initial Effect run when Strict Mode is absent at the root.
Keep the check useful. In useOnlineStatus, setup adds listeners and cleanup removes them, so restarting does not accumulate subscriptions. A ref that skips the next setup can leave a component disconnected after cleanup and conceal the real issue. Production can also remount components. Make synchronization correct across restarts instead of relying on a particular number of Effect calls.
A debugging checklist and a 20-minute exercise
Explain the preview's feedback path before editing. Remove derived state, add a user-controlled minimum price, and verify both prop replacement and input changes. Then discuss an external price-feed subscription: which values would be calculated, and which would need synchronization?
- Name the external system, or remove the Effect if the result is already available from props and state.
- Describe the particular state write and changed dependency that close a loop; two logs alone do not establish one.
- Match dependencies to the reactive values the code actually reads. Change the code when removing a dependency, instead of suppressing the warning.
- Check cleanup with pending requests and active subscriptions, then test rapid changes and unmounting.
- Explain one rejected fix: an empty array would freeze the product preview, while memoizing options would preserve unnecessary derived state.
Quick answers
Frequently asked questions
Why does useEffect cause an infinite loop?
Trace a state update from the Effect to a render that changes an Effect dependency. Fresh objects, arrays or functions often expose that path. Remove unnecessary derived state first, then inspect any real external synchronization.
Does an empty dependency array guarantee one run?
No. A new mount starts setup again, and root Strict Mode adds a development check. An empty list is appropriate only when the setup does not read changing reactive inputs. It cannot safely freeze a calculation that should follow props.
Should I fix every loop with useCallback or useMemo?
First decide whether the Effect is needed. For derived UI data, remove it. For external synchronization, arrange the code around meaningful inputs and create setup-only objects inside the Effect when appropriate. Caching is a separate decision.
Can the useEffect callback itself be async?
An async function returns a Promise, whereas the Effect callback must return cleanup or nothing. Put the async function inside setup, call it there, and return a synchronous cleanup function that invalidates or cancels that setup's work.
Source notes
References and review policy
Information checked on October 2, 2026. Section links identify sources for factual claims and technical explanations. Interpretations, practice scenarios and preparation recommendations are RecallDeck’s editorial work.
- React: useEffect reference and troubleshooting
- React: Lifecycle of Reactive Effects
- React: You Might Not Need an Effect
- React: Removing Effect Dependencies
- React: exhaustive-deps lint rule
- React: Separating Events from Effects
- React: StrictMode checks
- React: Synchronizing with Effects
- MDN: AbortController
- React: useMemo reference
- React: useState reference
- MDN: navigator.onLine and connectivity limitations
- MDN: Clipboard.writeText and secure contexts
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.