Deep Dive: Solving Race Conditions and Stale Closures in React Refs
When building high-performance animation libraries in React, handling element synchronization smoothly is everything. Frameworks must track DOM nodes flawlessly to calculate layout deltas, handle exits, and orchestrate transitions.
Recently, I investigated a subtle but critical bug in Framer Motion where components failed to update layout references dynamically when an external React ref changed. In this article, I break down the root cause of the reference synchronization bug, the architectural race condition it introduced, and how the fix was peer-reviewed and merged into production.
Merged PR: motiondivision/motion#3366 — fix: update useMotionRef to properly handle externalRef changes
The Bug: Ghost Elements and Ref Stagnation
The issue surfaced when consumers updated an external mutable ref passed into a motion element. Under highly dynamic component trees—multi-step forms, conditional dashboard layouts, or sliding tab panes—the motion component would freeze its internal reference to the old DOM node and fail to re-bind to the newly mapped node.
That led to:
- Animation artifacts
- Broken layout projections
- Layout engine mismatches where animations fired from a ghost position of a detached node
Related issue: #3361 — Updating the ref to a motion element does not work
The Problematic Implementation
Inside Framer Motion, a custom hook named useMotionRef combines internal layout tracking with an optional forwarded ref from the developer.
The original implementation looked like this:
// packages/framer-motion/src/motion/utils/use-motion-ref.ts
export function useMotionRef(visualElement, externalRef) {
return useCallback((instance) => {
// Set the internal framework node tracker
visualElement.mount(instance);
// Sync with the developer's forwarded ref
if (externalRef) {
setRef(externalRef, instance);
}
}, [visualElement]); // <-- Notice anything missing?
}
Root Cause Analysis: Stale Closures in useCallback
The core flaw was a classic React dependency oversight. Because externalRef was omitted from the useCallback dependency array, the hook kept a stale closure over the initial ref from the first render.
When the parent remounted or pointed the ref at a different DOM node, useMotionRef still ran the memoized callback from the previous render cycle.
| Symptom | Effect |
|---|---|
| Stale tracking | The ref handler did not cleanly unlink the old instance |
| Missed lifecycles | The new DOM instance did not get visualElement.mount
|
| Visual artifacts | The layout engine kept using stale matrix / box data |
The Solution: Dependency Tracking
The fix adds externalRef to the dependency array so React invalidates the cached callback when the forwarded ref changes.
// packages/framer-motion/src/motion/utils/use-motion-ref.ts
export function useMotionRef(visualElement, externalRef) {
return useCallback((instance) => {
visualElement.mount(instance);
if (externalRef) {
setRef(externalRef, instance);
}
}, [visualElement, externalRef]); // Fixed: track externalRef to avoid stale closures
}
What changed in the PR
- Added
externalRefto theuseCallbackdependency array inuse-motion-ref.ts - Added regression test:
packages/framer-motion/src/motion/__tests__/ref-change.test.tsx - Updated
CHANGELOG.md
Regression Testing
A targeted integration test uses React Testing Library to swap which ref is passed to the same motion element and assert that refs clear and re-bind correctly.
// packages/framer-motion/src/motion/__tests__/ref-change.test.tsx
import * as React from "react";
import { motion } from "../";
import { render } from "@testing-library/react";
test("should properly update and synchronize refs when externalRef shifts dynamically", () => {
const RefWrapper = ({ activeRef }) => {
return <motion.div ref={activeRef} data-testid="motion-node" />;
};
const firstRef = React.createRef();
const secondRef = React.createRef();
const { rerender } = render(<RefWrapper activeRef={firstRef} />);
expect(firstRef.current).not.toBeNull();
// Trigger explicit reference mutation swap
rerender(<RefWrapper activeRef={secondRef} />);
expect(secondRef.current).not.toBeNull();
});
Key Takeaways
-
Memoization is double-edged —
useCallbackanduseMemocut re-renders, but missing dependencies cause silent stale state that is hard to debug. - Frameworks need defense in depth — Layout engines must react when consumer-provided refs change; do not assume forwarded refs are static.
- Test dynamic ref swaps — Conditional trees and ref reassignment deserve explicit regression tests.
The fix was merged into motiondivision/motion main after CI passed, resolving the underlying bug for conditional Framer Motion usage.
Top comments (0)