useeffect-dependencies
useEffect Dependency Fixer
Phase 1 — Diagnose Before Touching Anything
Identify the actual bug category before suggesting any fix. Most mistakes come from misdiagnosing the category.
| Symptom | Likely Category |
|---|---|
| ESLint error, effect works fine | Missing dep that happens to be stable |
| Infinite re-render loop | Object/function dep recreated on every render |
| Stale value read inside effect | Closure captures old binding |
| Effect never re-runs when it should | Dep omitted or effect has wrong placement |
useCallback chain grew just to satisfy deps |
Stabilization needed, not suppression |
Phase 2 — The Non-Obvious Rules
2.1 Referential Stability Is the Real Problem
exhaustive-deps errors on objects/functions almost never mean "add the dep." They mean the dep is unstable. Adding it causes an infinite loop; omitting it causes a stale closure. The fix is stabilization upstream.
Decision tree:
- Function defined in component body? →
useCallbackit, or move it outside the component if it doesn't use component state. - Object defined in component body? →
useMemoit, or destructure to primitives. - Value from a library hook that returns a new reference each render? → Check the library's docs — most stable values (e.g.,
dispatchfromuseReducer,set*fromuseState) are guaranteed stable and can be safely omitted from the array (though including them is also fine).
2.2 The Legitimate eslint-disable Cases
These are the only situations where suppressing exhaustive-deps is defensible:
- On-mount-only intent — intentionally run once. Use
// eslint-disable-next-line react-hooks/exhaustive-depswith a comment explaining intent. Do NOT use an empty[]silently. - Polling interval — the interval callback needs access to latest state but you don't want to reset the interval on every state change. Use a ref to hold the latest value (see §2.4).
- Third-party DOM lib — effect initializes a lib (e.g., chart, map) once; re-running it destroys and recreates the instance. Document this explicitly.
previousValuepattern — you're intentionally reading a stale value to compare against current.
For every other case: fix the dep array, don't suppress.
2.3 Stale Closure Fixes
Pattern: state or prop read inside a callback passed to a timer/subscription
// ❌ stale closure — count captured at setup time
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always uses initial count
}, 1000);
return () => clearInterval(id);
}, []);
// ✅ functional updater — no closure needed
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Pattern: stale callback in event listener or subscription
// ✅ ref pattern — always reads latest value without re-registering
const onMessageRef = useRef(onMessage);
useLayoutEffect(() => { onMessageRef.current = onMessage; });
useEffect(() => {
const unsub = socket.on('msg', (e) => onMessageRef.current(e));
return unsub;
}, [socket]); // socket is the only real dep
Use useLayoutEffect (not useEffect) for the ref sync to prevent a window where the ref is stale between render and paint.
2.4 The "Latest Ref" Pattern (Canonical)
When you need latest-value access without re-triggering effects:
function useLatest<T>(value: T) {
const ref = useRef(value);
useLayoutEffect(() => { ref.current = value; });
return ref;
}
Use this instead of inline ref sync whenever the pattern appears more than once.
2.5 Object/Array Deps — Stabilize, Don't Stringify
Wrong approach: JSON.stringify(obj) as a dep. Breaks on non-serializable values, hides the real issue, causes bugs with key ordering.
Right approach:
- Destructure to primitives:
const { id, type } = config;then dep onid, type - If the whole object must be stable,
useMemoit at the call site - If it comes from props, the parent is responsible for memoizing
2.6 When useCallback Chains Are the Problem
If fixing a useEffect dep causes you to useCallback a function, and that function calls another function that also needs useCallback, stop. You're papering over a design issue.
Refactor instead:
- Move the function chain outside the component if it doesn't use component state/props
- Or pass only primitive args to the effect and reconstruct the function inside the effect body (effect-local functions don't need to be in deps)
- Or use
useReducer— dispatch is stable, so complex logic moves into the reducer
// ❌ useCallback chain
const getPayload = useCallback(() => ({ userId, token }), [userId, token]);
const fetchUser = useCallback(() => fetch(getPayload()), [getPayload]);
useEffect(() => { fetchUser(); }, [fetchUser]);
// ✅ primitives into effect, function is effect-local
useEffect(() => {
const fetchUser = () => fetch({ userId, token });
fetchUser();
}, [userId, token]);
Phase 3 — Execution Pattern
- Read the full effect + surrounding component scope. Don't fix in isolation.
- Identify every variable the effect closes over.
- Classify each as: primitive stable / primitive unstable / object/function stable / object/function unstable.
- For unstable references: stabilize upstream before touching the dep array.
- Write the fix. If suppression is unavoidable, add an inline comment explaining why.
- Check for cleanup — if the dep array changes, the cleanup/re-setup cycle must be correct.
Phase 4 — Output
Produce:
- Fixed code with inline comments on non-obvious decisions
- One-line explanation per changed dep: what it was, what it is, why
- Flag if the root cause is a parent component not memoizing a prop (actionable for the caller)
- If a
useCallbackchain was the cause, show the collapsed version
Do not produce: generic eslint-disable wrappers, JSON.stringify hacks, or dep arrays that will cause infinite loops.
More from blunotech-dev/agents
anti-purple-ui
Enforce a strict monochrome UI with a single high-contrast accent color, removing generic tech gradients and “AI-style” palettes. Use when the user wants minimal, anti-AI, or non-generic aesthetics, or says the UI looks too techy or generic.
9harmonize-whitespace
Align all spacing (padding, margins, gaps) to a consistent 4pt/8pt grid. Use when spacing feels off, inconsistent, cramped, or unbalanced, or when the user asks for a spacing scale or alignment fix.
9typographic-hierarchy
Improve typography by adjusting font sizes, weights, spacing, and contrast to create clear visual hierarchy and readability. Use when text feels flat, unstructured, or when the user asks to refine headings, type scale, or overall readability.
6micro-interaction-adder
Add polished CSS micro-interactions like hover effects, transitions, and feedback states to improve UI feel. Use when the user asks for animations, better UX, or when the interface feels static, plain, or unresponsive.
4consistent-border-radius
Normalizes rounded corners across a file so buttons, inputs, cards, modals, badges, and all UI elements share the exact same curvature. Use this skill whenever the user mentions inconsistent border radii, wants to unify rounded corners, asks to make UI elements look more cohesive, or says things like "make the corners match", "fix the rounding", "unify border radius", "standardize my rounded corners", or "buttons and cards don't match". Also trigger when the user pastes a CSS/HTML/JSX/TSX file and asks for a design consistency pass, border radius is one of the first things to normalize.
4component-split
Analyze a component and determine when and how to split it based on size, responsibility, and reuse signals, producing a refactored structure with clear boundaries. Use when users share large, mixed-concern, or hard-to-test components, or ask about splitting, refactoring, or improving component architecture.
3