React 20 ref Cleanup Functions: The Return Value That Finally Fixes Your Event Listener Leaks
React 20's ref cleanup return value eliminates the useEffect boilerplate that caused most DOM event listener memory leaks. Learn when ref cleanup replaces useEffect and when it does not.
Most memory leaks in React applications stem from event listeners that never get removed. Developers attach a scroll listener in a ref callback, the component unmounts, and that listener sits in memory forever because nothing cleaned it up. The standard solution has been to move that logic into useEffect with a cleanup return, which works but bloats the component with an entire hook just to mirror what the ref already knows.
%% alt: problem flow showing ref attaching listener that never gets cleaned up
flowchart LR
A("Component mounts") --> B("Ref callback fires")
B --> C("Event listener attached")
C --> D("Component unmounts")
D --> E("Listener stays in memory")
style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
React 20 introduces ref cleanup functions. When a ref callback returns a function, React calls that function when the element detaches or the ref changes. This eliminates the useEffect boilerplate and puts cleanup exactly where the attachment happens.
%% alt: solution flow showing ref attaching listener and cleanup function removing it
flowchart LR
A("Component mounts") --> B("Ref callback fires")
B --> C("Event listener attached")
C --> F("Ref returns cleanup")
F --> D("Component unmounts")
D --> G("Cleanup removes listener")
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The implication here is that teams can delete dozens of useEffect hooks whose sole purpose was cleaning up DOM side effects. That means fewer dependencies to track, fewer renders to reason about, and cleanup that cannot drift from attachment because they live in the same function body.
Key Takeaways
- React 20 ref callbacks can return a cleanup function that runs when the element detaches or the ref changes, removing the need for a separate useEffect.
- Most event listener memory leaks disappear when cleanup lives in the same ref callback that attached the listener, eliminating dependency-tracking mistakes.
- Ref cleanup replaces useEffect for DOM-only side effects like ResizeObserver, IntersectionObserver, and custom event listeners that do not depend on component state.
- useEffect cleanup still owns async operations, state-dependent subscriptions, and logic that must respond to prop changes beyond DOM attachment.
- Migrating to ref cleanup reduces component size, simplifies mental models, and makes cleanup failures impossible by colocating attachment and removal.
Why Event Listeners in Refs Leak Memory
The broken pattern appears when developers attach an event listener directly in a ref callback without cleanup. The listener registers on mount, the component unmounts, and nothing removes it. The DOM node might disappear from the tree, but the event system still holds a reference to the callback, and that callback likely closes over component state or props. The result is a memory leak that grows with every mount and unmount cycle.
%% alt: sequence showing event listener attachment without cleanup causing memory retention sequenceDiagram participant C as Component participant R as Ref Callback participant D as DOM Element participant E as Event System C->>R: Mount triggers ref R->>D: Attach scroll listener D->>E: Register callback Note over E: Callback captures props/state C->>C: Unmount D-->>E: Element removed from tree Note over E: Listener still registered<br/>Callback still in memory
The failure mode here is subtle but expensive. A single scroll listener on a list item component means every item that ever rendered leaves a ghost listener behind. Open a modal with a hundred items, close it, and those hundred listeners persist. Do that ten times in a session and the page carries a thousand dead callbacks. Performance degrades. Memory climbs. The profiler shows anonymous functions piling up. Developers notice the symptoms but struggle to trace them back to the missing cleanup in a ref callback from weeks ago.
The traditional fix moves the listener into useEffect with a cleanup return. That works. The cleanup runs on unmount. The leak stops. But it doubles the component's surface area. Now you have a ref to get the element and a useEffect to attach the listener. The ref becomes a dependency of the effect. If the ref identity changes, the effect re-runs. If someone forgets to include the ref in the dependency array, ESLint yells. If they override ESLint, the listener attaches to a stale element. The pattern that should be three lines becomes ten, and every line is a place for mistakes.
This distinction is critical. The ref already knows when the element appears and disappears. The ref callback fires when the element mounts and when it changes. The information needed for cleanup lives in the ref callback's execution context. Forcing that logic into a separate hook fractures the mental model and creates coordination overhead that should not exist.
React 20's ref Cleanup Return Value: How It Works
Ref cleanup works by returning a function from the ref callback. When React attaches the ref to an element, it calls the callback with the element. When React detaches the ref (because the component unmounted or the ref changed to a different element), it calls the returned cleanup function. The pattern mirrors useEffect cleanup but lives directly in the ref callback, colocating attachment and removal in a single function body.
function ScrollLogger() {
const scrollRef = (element: HTMLDivElement | null) => {
if (!element) return;
const handleScroll = () => {
console.log('Scroll position:', element.scrollTop);
};
element.addEventListener('scroll', handleScroll);
// Cleanup function runs when element detaches
return () => {
element.removeEventListener('scroll', handleScroll);
};
};
return (
<div ref={scrollRef} style={{ height: '200px', overflow: 'auto' }}>
{Array.from({ length: 100 }, (_, i) => (
<div key={i}>Item {i}</div>
))}
</div>
);
}The ref callback receives the element, attaches the listener, and returns the cleanup. React stores that cleanup and invokes it when the ref detaches. No useEffect. No dependency array. No coordination between two hooks. The attachment and removal live in the same lexical scope, making it impossible for cleanup to reference a different element than attachment.
The timing matches useEffect cleanup. React calls the cleanup before the next ref callback fires or when the component unmounts. If the ref changes from one element to another, React calls the cleanup for the old element, then calls the ref callback with the new element. This ensures that at most one listener exists at any time, and that listener always points to the current element.
function ResizeTracker() {
const resizeRef = (element: HTMLElement | null) => {
if (!element) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.log('New size:', entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(element);
return () => {
observer.disconnect();
};
};
return (
<div ref={resizeRef} style={{ resize: 'both', overflow: 'auto', border: '1px solid gray', padding: '20px' }}>
Resize this box
</div>
);
}The ResizeObserver pattern shows the power of ref cleanup. The observer attaches to the element, the cleanup disconnects it, and there is no way to forget the cleanup because it returns from the same function that created the observer. The observer lifecycle matches the element lifecycle perfectly. No state. No effect. No dependencies. Just a ref callback that owns its own cleanup.
Migrating From useEffect Cleanup to Ref Cleanup
The useEffect version of the scroll logger requires a ref to store the element, a useEffect to attach the listener, and a dependency array to re-run when the ref changes. The cleanup function lives inside the effect, which means the effect must capture the element in its closure and clean up the previous listener before attaching a new one.
%% alt: comparison of useEffect cleanup versus ref cleanup showing ref cleanup eliminates the effect hook
flowchart LR
subgraph Old["useEffect Pattern"]
A1("useRef stores element") --> A2("useEffect reads ref.current")
A2 --> A3("Attach listener")
A3 --> A4("Return cleanup in effect")
A4 --> A5("Effect re-runs on ref change")
end
subgraph New["Ref Cleanup Pattern"]
B1("ref callback receives element") --> B2("Attach listener")
B2 --> B3("Return cleanup from callback")
end
style A5 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style B3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The useEffect approach looks like this:
function ScrollLoggerOld() {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
const handleScroll = () => {
console.log('Scroll position:', element.scrollTop);
};
element.addEventListener('scroll', handleScroll);
return () => {
element.removeEventListener('scroll', handleScroll);
};
}, []); // Often missing scrollRef in deps, or triggers unwanted re-runs
return (
<div ref={scrollRef} style={{ height: '200px', overflow: 'auto' }}>
{Array.from({ length: 100 }, (_, i) => (
<div key={i}>Item {i}</div>
))}
</div>
);
}The ref cleanup version collapses this into a single callback:
function ScrollLoggerNew() {
const scrollRef = (element: HTMLDivElement | null) => {
if (!element) return;
const handleScroll = () => {
console.log('Scroll position:', element.scrollTop);
};
element.addEventListener('scroll', handleScroll);
return () => {
element.removeEventListener('scroll', handleScroll);
};
};
return (
<div ref={scrollRef} style={{ height: '200px', overflow: 'auto' }}>
{Array.from({ length: 100 }, (_, i) => (
<div key={i}>Item {i}</div>
))}
</div>
);
}The difference is immediate. The new version has no useRef, no useEffect, and no dependency array. The cleanup lives in the same function that attached the listener. The ref callback executes once when the element mounts and the cleanup executes once when it unmounts. There is no way to introduce a stale closure or a missing dependency because the attachment and cleanup share the same lexical scope.
Migration targets are DOM-only side effects. If the useEffect attaches a listener, creates an observer, or manipulates the DOM without reading component state or props, that effect becomes a ref cleanup. If the effect depends on state or props, it stays as useEffect because ref cleanup does not re-run on state changes.
Real-World Patterns: ResizeObserver, IntersectionObserver, and Custom Listeners
ResizeObserver tracks element size changes. The observer attaches on mount and disconnects on unmount. Ref cleanup handles this perfectly.
function ImageLazyLoader() {
const lazyRef = (img: HTMLImageElement | null) => {
if (!img) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
img.src = img.dataset.src || '';
observer.unobserve(img);
}
}
},
{ threshold: 0.1 }
);
observer.observe(img);
return () => {
observer.disconnect();
};
};
return <img ref={lazyRef} data-src="https://example.com/image.jpg" alt="Lazy loaded" />;
}The IntersectionObserver pattern shows a single-use observer that loads an image when it enters the viewport. The observer disconnects after the first intersection, but the ref cleanup still runs on unmount to handle cases where the image never entered the viewport. The cleanup guarantees no observer leaks regardless of whether the intersection fired.
%% alt: flow showing ref cleanup handling ResizeObserver, IntersectionObserver, and custom event listeners
flowchart LR
A("ref callback fires") --> B("Create observer or attach listener")
B --> C("Return cleanup function")
C --> D("Element detaches")
D --> E("Cleanup disconnects observer")
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Custom event listeners for window or document follow the same pattern. A component that tracks keyboard shortcuts attaches a listener to document and removes it on unmount.
function KeyboardShortcuts() {
const containerRef = (element: HTMLDivElement | null) => {
if (!element) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
console.log('Save triggered');
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
};
return <div ref={containerRef}>Press Ctrl+S to save</div>;
}The listener attaches to document, not to the ref element, but the ref callback still owns the cleanup. When the component unmounts, the cleanup removes the document listener. The ref element serves as the lifecycle anchor. The listener lifetime matches the component lifetime because the ref callback fires on mount and the cleanup fires on unmount.
Focus management for modals or dropdowns uses ref cleanup to restore focus to the previously focused element when the component unmounts.
function Modal({ children }: { children: React.ReactNode }) {
const modalRef = (element: HTMLDivElement | null) => {
if (!element) return;
const previousFocus = document.activeElement as HTMLElement;
element.focus();
return () => {
previousFocus?.focus();
};
};
return (
<div ref={modalRef} tabIndex={-1} style={{ padding: '20px', border: '2px solid black' }}>
{children}
</div>
);
}The modal captures the previously focused element when it mounts, moves focus to itself, and restores the previous focus on unmount. The cleanup runs automatically. No useEffect. No layout effect. Just a ref callback that owns the focus lifecycle.
When Ref Cleanup Beats useEffect and When It Does Not
Ref cleanup wins when the side effect targets the DOM element directly and does not depend on component state or props. Event listeners, observers, focus management, and direct DOM manipulation all fit this category. The attachment and cleanup happen at the element lifecycle boundaries. The ref callback fires when the element appears, the cleanup fires when it disappears, and nothing in between matters.
%% alt: decision flow showing when to use ref cleanup versus useEffect cleanup
flowchart LR
A("Side effect needed") --> B{"Targets DOM element?"}
B -->|Yes| C{"Depends on state/props?"}
B -->|No| D("Use useEffect")
C -->|No| E("Use ref cleanup")
C -->|Yes| D
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#7c9cf0,fill:#142544,color:#eaf2ff
useEffect cleanup remains essential for three categories. First, async operations like fetch requests, timers, and WebSocket connections depend on state and need cleanup that responds to state changes. Ref cleanup runs only when the element detaches, not when state changes, so it cannot cancel a fetch when a search query updates.
// useEffect cleanup for async operations
function DataFetcher({ userId }: { userId: string }) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setData);
return () => {
controller.abort(); // Must cancel on userId change
};
}, [userId]);
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}Second, subscriptions that depend on props or state need useEffect cleanup. A real-time subscription to a chat room must re-subscribe when the room ID changes. Ref cleanup cannot detect that change because it only runs on element detachment.
// useEffect cleanup for subscriptions
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const subscription = chatService.subscribe(roomId, (message) => {
console.log('New message:', message);
});
return () => {
subscription.unsubscribe(); // Must unsubscribe on roomId change
};
}, [roomId]);
return <div>Chat room {roomId}</div>;
}Third, side effects that do not attach to a specific DOM element belong in useEffect. Setting document title, updating localStorage, or logging analytics events have no element to anchor to. The ref would be artificial, and the cleanup would not map to any meaningful DOM lifecycle.
The distinction is critical. Ref cleanup replaces useEffect only when the side effect is DOM-scoped and state-independent. When state or props drive the side effect, useEffect remains the correct tool. Teams that try to force all cleanup into refs end up with bugs where cleanup does not run on state changes or runs at the wrong time.
A hybrid pattern appears when a component needs both. A video player might attach play and pause listeners in a ref cleanup and subscribe to a playback state stream in a useEffect. The ref cleanup owns the DOM listeners. The useEffect owns the state subscription. Both cleanups coexist without conflict.
function VideoPlayer({ src }: { src: string }) {
const [isPlaying, setIsPlaying] = useState(false);
// Ref cleanup for DOM listeners
const videoRef = (video: HTMLVideoElement | null) => {
if (!video) return;
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
};
// useEffect cleanup for state-dependent logic
useEffect(() => {
console.log('Playback state changed:', isPlaying);
// Could trigger analytics, update external state, etc.
}, [isPlaying]);
return <video ref={videoRef} src={src} controls />;
}The video element listeners attach and detach with the element. The playback state logging responds to state changes. Each cleanup mechanism handles its own domain. The result is cleaner than forcing both into useEffect or trying to make ref cleanup respond to state.
Frequently Asked Questions
Does ref cleanup work with useRef and callback refs?
Ref cleanup only works with callback refs. The function you pass to the ref prop can return a cleanup function. useRef returns a mutable object whose current property holds the element, but React does not call useRef as a function so there is no place to return cleanup. Teams using useRef must continue pairing it with useEffect for cleanup.
Can I return cleanup from a ref callback that receives null?
Yes, but the cleanup will not run because React calls the callback with null when the element detaches, and returning a function from that null-case callback serves no purpose. The cleanup should return from the non-null case when the element attaches. When the element detaches, React calls the cleanup from the previous non-null call before calling the callback with null.
Does ref cleanup run on every render or only on mount and unmount?
Ref cleanup runs when the ref changes, which typically means mount and unmount. If the ref callback itself changes identity between renders, React treats it as a new ref, calls the cleanup from the old callback, and calls the new callback with the element. To prevent this, define the callback outside the render function or wrap it in useCallback. Most teams define the callback inline and accept that it re-runs on every render, which is fine for lightweight attachments but wasteful for heavy observers.
What happens if the ref callback returns a non-function value?
React ignores it. The cleanup return is optional. If the callback returns nothing or returns a non-function value, React proceeds as if no cleanup exists. This matches useEffect behavior where returning undefined or a non-function is valid but means no cleanup.
Can I use async functions as ref callbacks to return cleanup?
No. An async function returns a Promise, not a cleanup function. React expects a synchronous function return. If you need async logic in a ref callback, run the async operation without awaiting it and return a synchronous cleanup. If cleanup depends on the async result, use useEffect instead.
Conclusion: Cleaner DOM Interactions Without Extra Hooks
React 20 ref cleanup eliminates the useEffect boilerplate that caused most DOM event listener leaks. The cleanup return value colocates attachment and removal in a single callback, making it impossible to forget cleanup or attach it to the wrong element. Teams migrating DOM-only side effects from useEffect to ref cleanup will delete dozens of hooks, simplify mental models, and ship components that cannot leak because the cleanup cannot drift from the attachment. useEffect cleanup remains essential for async operations, state-dependent subscriptions, and logic that responds to prop changes, but for direct DOM manipulation the ref callback now owns its own lifecycle. That covers the essential patterns for ref cleanup. Apply these in production and the difference will be immediate.