Use when writing React hooks with object/array dependencies, debugging infinite re-renders, or reviewing code with useEffect/useCallback/useMemo - provides patterns to prevent reference instability causing infinite loops
Systematic patterns for preventing reference instability in React hook dependencies.
React hooks compare dependencies by reference, not value. Objects and arrays are recreated on every render, causing:
useEffect running infinitelyuseCallback/useMemo invalidating every renderInvoke this skill when:
useEffect with object/array dependenciesUse when: Array/object content should trigger effect, not reference change.
Problem:
// ❌ WRONG - assets array recreated every render = infinite loop
const [result, setResult] = useState(null);
useEffect(() => {
processAssets(assets).then(setResult);
}, [assets]); // Reference changes every render!
Solution:
// ✅ RIGHT - stable string key only changes when content changes
const assetsKey = useMemo(
() =>
assets
.map((a) => a.id)
.sort()
.join(","),
[assets]
);
useEffect(() => {
processAssets(assets).then(setResult);
}, [assetsKey]); // String comparison, stable reference
When to use: Entity arrays with stable IDs (assets, users, products).
Use when: Need latest callback value without triggering re-render.
Problem:
// ❌ WRONG - callback in dependency causes loop
const handleUpdate = useCallback(() => {
onUpdate(data);
}, [onUpdate, data]); // onUpdate might be unstable
Solution:
// ✅ RIGHT - ref holds latest without triggering re-render
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
const handleUpdate = useCallback(() => {
onUpdateRef.current(data);
}, [data]); // Only data in deps
When to use: Callbacks from props, event handlers passed from parent.
Use when: Config object should trigger effect on value change.
// For config objects that should trigger on value change
const configKey = useMemo(() => JSON.stringify(config), [config]);
useEffect(() => {
applyConfig(config);
}, [configKey]);
⚠️ Limitations: Only works for JSON-serializable objects (no functions, undefined, circular refs).
Use when: Parent passes callbacks that may not be memoized.
// When parent passes unstable callbacks
const stableOnChange = useCallback(
(value: T) => onChange?.(value),
[] // Empty deps - uses closure
);
// Access latest onChange via ref if needed
const onChangeRef = useRef(onChange);
useLayoutEffect(() => {
onChangeRef.current = onChange;
});
When to use: Creating reusable components that accept callback props.
| Anti-Pattern | Detection | Fix |
|---|---|---|
| Array/object literal in deps | }, [{ foo }]) or }, [[a,b]]) | Extract to useMemo/serialize |
| Function in deps without useCallback | }, [someFunction]) | Wrap in useCallback or ref |
| Spreading props into deps | }, [...props]) | List specific props |
| New object in useCallback deps | }, [{ config }]) | Serialize or use ref |
| Inline arrow functions in deps | }, [() => doThing()]) | Extract to useCallback |
See: Anti-Patterns Reference for detection strategies and automated linting rules.
When infinite loop occurs:
Confirm loop: Add console.log at start of useEffect
Identify culprit: Log each dependency with reference identity
useEffect(() => {
console.log("Effect running", { dep1, dep2, dep3 });
}, [dep1, dep2, dep3]);
Check reference stability: Which dep has different reference each log?
Apply pattern: Use appropriate pattern from Core Patterns above
Verify fix: Remove logs, confirm loop stopped
See: Debugging Guide for React DevTools profiling and advanced techniques.
Location: modules/chariot/ui/src/components/nodeGraph/hooks/useClusterManagement.ts
Pattern Used: Serialized dependency key
const serializedAssets = useMemo(
() =>
assets
.map((a) => a.id)
.sort()
.join(","),
[assets]
);
useEffect(() => {
detectClusters(assets);
}, [serializedAssets]); // Prevents re-clustering on every render
Location: modules/chariot/ui/src/components/nodeGraph/hooks/useViewportCulling.ts
Pattern Used: Ref for callback stability
const debounceTimerRef = useRef<NodeJS.Timeout>();
const handleCameraMove = useCallback(() => {
// Uses ref to avoid callback dependencies
}, []); // Empty deps, stable across renders
See: Chariot Examples for complete implementations with context.
frontend-developer agent - When implementing React hooksfrontend-reviewer agent - During code review for performancefrontend-security agent - When reviewing client-side rendering logic| Skill | When | Purpose |
|---|---|---|
debugging-systematically | When loop already exists | Root cause analysis |
optimizing-react-performance | For broader perf issues | Context for optimization decisions |
None - terminal skill (provides patterns, doesn't orchestrate other skills)
| Skill | Trigger | Purpose |
|---|---|---|
optimizing-react-performance | Performance issues beyond hook loops | Broader React optimization |
adhering-to-dry | Multiple components have same patterns | Extract reusable utilities |
testing-react-hooks | Need tests for custom hooks | Hook testing patterns |
Query keys follow same serialization pattern:
const queryKey = useMemo(
() => ["assets", filters.status, filters.class],
[filters.status, filters.class] // Only stable values
);
Selector stability:
const stableSelector = useCallback(
(state) => state.assets.filter((a) => a.status === status),
[status] // Only status, not filter function
);
Watch dependencies:
const watchedFields = useWatch({ control, name: ["field1", "field2"] });
const fieldKey = useMemo(() => JSON.stringify(watchedFields), [watchedFields]);
optimizing-react-performance - Broader performance patterns (memoization, virtualization)debugging-systematically - When tracking down the loop source (root cause analysis)adhering-to-dry - Extract reusable serialization utilitiestesting-react-hooks - Testing patterns for custom hooks with complex dependenciesFor advanced patterns and edge cases, see:
✅ No "Maximum update depth exceeded" errors ✅ useEffect runs only when logical dependencies change ✅ Browser profiler shows no excessive re-renders ✅ ESLint exhaustive-deps rule passes with no suppressions ✅ Custom hooks work correctly when parent re-renders