The Three Ref-Driven Scroll Hooks I Actually Use
Four ref recipes that earn their keep: `scrollIntoView`, deps-driven scroll-to-top, multi-target callback refs in a Map, and the `forwardRef` + `useImperativeHandle` escape hatch.
By @rajtanaka
November 26, 2025
·
Updated May 18, 2026
618 views
12
4.4 (15)
// Recipe 1. The simplest one. Returns [ref, scroll]. Caller attaches ref to
// any DOM node, then calls scroll(opts) to bring it into view.
const { useRef, useCallback } = (typeof React !== 'undefined' ? React : {
useRef: (init) => ({ current: init }),
useCallback: (f) => f,
});
function useScrollIntoView(defaultOpts) {
const ref = useRef(null);
const scroll = useCallback((opts) => {
const node = ref.current;
if (!node) return false;
const merged = Object.assign({ behavior: 'smooth', block: 'center' }, defaultOpts || {}, opts || {});
if (typeof node.scrollIntoView === 'function') node.scrollIntoView(merged);
else console.log('would call scrollIntoView with', merged);
return true;
}, []);
return [ref, scroll];
}
// Stand-in for a real DOM node so the snippet runs anywhere.
const calls = [];
const fakeNode = { scrollIntoView: (opts) => { calls.push(opts); } };
const [ref, scroll] = useScrollIntoView();
console.log('scroll before attach ->', scroll());
ref.current = fakeNode;
scroll();
scroll({ block: 'start' });
scroll({ behavior: 'auto' });
console.log('captured calls:', JSON.stringify(calls));I write this hook in nine lines and reuse it everywhere a button needs to jump the page to a specific element. Returning [ref, scroll] mirrors useState's shape so the call site is symmetrical: const [errorRef, scrollToError] = useScrollIntoView(). Defaults of behavior: 'smooth' and block: 'center' are the values I want 95% of the time, and the per-call opts argument lets a caller override them when scrolling a list item to the top of a sticky-header page. The empty deps on useCallback are safe because the ref is a stable container; we read ref.current at call time, not capture time.
// Recipe 2. SPAs do not refresh the document scroll on a route change, so a
// long article followed by a short one leaves you halfway down the page. This
// hook watches a deps array and snaps a container to (0, 0) on every change.
// Mini React shim that defers effects to a flush step so refs can be attached
// between render-and-commit, mimicking real React behaviour.
const effectQueue = [];
let lastDeps = null;
function flushEffects() { const q = effectQueue.splice(0); q.forEach((fn) => fn()); }
const { useRef, useEffect } = (typeof React !== 'undefined' ? React : {
useRef: (init) => ({ current: init }),
useEffect: (fn, deps) => {
const equal = lastDeps && lastDeps.length === deps.length
&& lastDeps.every((d, i) => Object.is(d, deps[i]));
if (!equal) { effectQueue.push(fn); lastDeps = deps.slice(); }
},
});
function useScrollToTop(deps) {
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
if (typeof node.scrollTo === 'function') node.scrollTo(0, 0);
else if (typeof node.scrollTop === 'number') node.scrollTop = 0;
else console.log('would scroll node to (0, 0)');
}, deps);
return ref;
}
// Stand-in container that records every scrollTo call for inspection.
const container = { scrollTop: 1200, scrollTo(x, y) { this.scrollTop = y; this._calls = (this._calls || 0) + 1; } };
// We simulate a remount-style flow: each render attaches the container to the
// hook's ref before the deps-effect runs. In real React the JSX `ref={...}`
// happens in the same render the hook returns from.
function renderWith(deps) {
const ref = useScrollToTop(deps);
ref.current = container; // <ContentArea ref={ref}> in real code
flushEffects(); // commit phase: refs are attached, now effects fire
return ref;
}
renderWith(['/articles/a']); // first nav fires the effect, scrolls to 0
console.log('after first route, scrollTop ->', container.scrollTop, '| total scrollTo calls:', container._calls);
container.scrollTop = 500; // user scrolls down within the page
renderWith(['/articles/a']); // same deps, effect skipped
console.log('same route, scrollTop preserved ->', container.scrollTop);
renderWith(['/articles/b']); // deps changed, effect fires
console.log('after route change, scrollTop ->', container.scrollTop, '| total scrollTo calls:', container._calls);This is the hook I forget exists until a QA bug asks why the second article on a navigation chain loads scrolled to the middle. Watching [location.pathname] (or whatever your router exposes) is enough: any route change snaps the container to top, intra-page scroll is left alone. I prefer scrollTo(0, 0) over window.scrollTo because the scroll usually lives on a content container, not the document, in modern layouts with a fixed header and sidebar. The fallback to node.scrollTop = 0 exists because some old test environments provide partial DOM stand-ins; in real browsers you can drop it.
// Recipe 3. A page with N sections and a top nav that jumps to each. Single
// useRef per section is fine until N is dynamic; then I want a callback ref
// that registers each node in a shared Map keyed by id.
const { useRef, useCallback } = (typeof React !== 'undefined' ? React : {
useRef: (init) => ({ current: init }),
useCallback: (f) => f,
});
function useScrollableSection() {
const mapRef = useRef(null);
if (mapRef.current === null) mapRef.current = new Map();
const register = useCallback((id) => (node) => {
const map = mapRef.current;
if (node) map.set(id, node);
else map.delete(id);
}, []);
const scrollTo = useCallback((id, opts) => {
const node = mapRef.current.get(id);
if (!node) { console.log('no section registered for', id); return false; }
const merged = Object.assign({ behavior: 'smooth', block: 'start' }, opts || {});
if (typeof node.scrollIntoView === 'function') node.scrollIntoView(merged);
else console.log('would scrollIntoView', id, 'with', merged);
return true;
}, []);
return { register, scrollTo, ids: () => Array.from(mapRef.current.keys()) };
}
// Stand-in nodes.
const nodeFor = (id) => ({ id, scrollIntoView(opts) { console.log('jumped to', id, 'with', opts.block); } });
const nav = useScrollableSection();
// React would call these as ref={register('intro')} on each section.
nav.register('intro')(nodeFor('intro'));
nav.register('install')(nodeFor('install'));
nav.register('api')(nodeFor('api'));
console.log('registered:', nav.ids());
nav.scrollTo('install');
nav.scrollTo('does-not-exist');
// Section unmount: callback ref runs with null, removing it from the Map.
nav.register('install')(null);
console.log('after unmount:', nav.ids());Callback refs are the right tool here because they fire when React attaches AND detaches the node, so unmount cleans up automatically without an effect. The register factory returns a closed-over callback per id; React calls it with the node on mount and with null on unmount, which is the exact lifecycle I want for a Map-backed registry. The mapRef.current === null lazy-init guard avoids paying the new Map() cost on every render, which adds up for components that re-render often. This pattern is what I reach for whenever a docs page, table of contents, or onboarding stepper needs to jump to a child section.
// Recipe 4. The `<Modal>` exposes open/close/focus to its parent without
// leaking the underlying DOM node. forwardRef forwards the ref, and
// useImperativeHandle replaces what `ref.current` resolves to.
const { useRef, useImperativeHandle, forwardRef } = (typeof React !== 'undefined' ? React : {
useRef: (init) => ({ current: init }),
useImperativeHandle: (ref, factory) => {
if (!ref) return;
if (typeof ref === 'function') ref(factory());
else ref.current = factory();
},
forwardRef: (renderFn) => (props, ref) => renderFn(props || {}, ref),
});
const Modal = forwardRef(function Modal(props, ref) {
// useRef-backed state so the imperative handle stays correct without
// re-rendering on every open/close (this matches what I ship in real apps).
const stateRef = useRef({ open: false });
const dialogRef = useRef(null); // would be attached to the actual <dialog>
const handle = {
open: () => { stateRef.current.open = true; },
close: () => { stateRef.current.open = false; },
focus: () => {
const node = dialogRef.current;
if (node && typeof node.focus === 'function') node.focus();
else console.log('would focus modal dialog');
},
isOpen: () => stateRef.current.open,
};
useImperativeHandle(ref, () => handle, []);
return handle; // playground stand-in for the rendered tree
});
const modalRef = { current: null };
Modal({ title: 'Confirm' }, modalRef);
console.log('exposed methods:', Object.keys(modalRef.current).sort().join(', '));
modalRef.current.open();
console.log('isOpen after open():', modalRef.current.isOpen());
modalRef.current.focus();
modalRef.current.close();
console.log('isOpen after close():', modalRef.current.isOpen());
console.log('parent never saw the underlying dialogRef:', !('dialogRef' in modalRef.current));I use this maybe twice a year, but when I need it nothing else fits. forwardRef lets a parent attach a ref to a function component, and useImperativeHandle substitutes what that ref resolves to. The win is that the parent gets exactly { open, close, focus, isOpen } and never the internal dialogRef, so I can refactor the modal's DOM later without breaking call sites. Storing the open flag in a ref keeps the handle's methods correct without depending on a re-render to publish a new closure; in a real component that also wants visual feedback, pair this with a useState mirror that triggers the actual dialog show/hide. Reach for this only when props-down-events-up genuinely cannot express the imperative trigger; otherwise it is just hidden coupling.
