useInView Hook With Hysteresis
An IntersectionObserver-based `useInView` that does not flip on/off when an element straddles the viewport edge. Uses two thresholds (enter and exit) so analytics events fire once per real visibility.
By @ethanhadid
November 25, 2025
·
Updated May 18, 2026
992 views
10
4.3 (14)
// Naive useInView: any time the threshold crosses, flip the state.
// Pure hysteresis logic isolated so we can unit-test it.
function stepNaive(visible, ratio, threshold) {
if (visible && ratio < threshold) return false;
if (!visible && ratio >= threshold) return true;
return visible;
}
function stepHysteresis(visible, ratio, enter, exit) {
if (!visible && ratio >= enter) return true;
if (visible && ratio <= exit) return false;
return visible;
}
// Drive both with a sequence of ratios that wobbles around the threshold,
// the way an item near the fold actually reports during a slow scroll.
const trace = [0.0, 0.3, 0.55, 0.45, 0.52, 0.48, 0.51, 0.7, 0.4, 0.1];
let naive = false;
let flips = 0;
for (const r of trace) {
const next = stepNaive(naive, r, 0.5);
if (next !== naive) flips++;
naive = next;
}
console.log('naive (single threshold 0.5) flipped', flips, 'times');
let smart = false;
let smartFlips = 0;
for (const r of trace) {
const next = stepHysteresis(smart, r, 0.6, 0.3);
if (next !== smart) smartFlips++;
smart = next;
}
console.log('hysteresis (0.6 enter / 0.3 exit) flipped', smartFlips, 'times');Single-threshold visibility detection looks fine in a demo and falls apart on real product pages. The wobbly trace above is what an actual IntersectionObserver reports when a user scrolls slowly past a card with a parallax background: 0.55, 0.45, 0.52, 0.48 over a few hundred milliseconds. With one threshold of 0.5 we get five state flips and five analytics.track('card_viewed') events; with separate enter (0.6) and exit (0.3) thresholds we get one. The pure helper means I can drive the table-test in vitest without spinning up IntersectionObserver at all.
// useInView: enter at 0.6, exit at 0.3. The threshold[] array passed to the
// observer must contain both values so the browser actually fires on each.
const { useState, useCallback, useRef, useEffect } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useRef: (init) => ({ current: init }),
useEffect: () => {},
});
function stepHysteresis(visible, ratio, enter, exit) {
if (!visible && ratio >= enter) return true;
if (visible && ratio <= exit) return false;
return visible;
}
function useInView(options) {
const enter = (options && options.enter) ?? 0.6;
const exit = (options && options.exit) ?? 0.3;
const [visible, setVisible] = useState(false);
const obsRef = useRef(null);
const visibleRef = useRef(false);
const setRef = useCallback((node) => {
if (obsRef.current) { obsRef.current.disconnect(); obsRef.current = null; }
if (!node || typeof IntersectionObserver === 'undefined') return;
const o = new IntersectionObserver((entries) => {
const e = entries[0]; if (!e) return;
const next = stepHysteresis(visibleRef.current, e.intersectionRatio, enter, exit);
if (next !== visibleRef.current) {
visibleRef.current = next;
setVisible(next);
}
}, { threshold: [exit, enter] });
o.observe(node);
obsRef.current = o;
}, [enter, exit]);
useEffect(() => () => { if (obsRef.current) obsRef.current.disconnect(); }, []);
return [setRef, visible];
}
const [setRef, visible] = useInView({ enter: 0.6, exit: 0.3 });
console.log('initial visible:', visible);
console.log('setRef accepts null:', setRef(null) === undefined);
console.log('setRef accepts node:', (setRef({ tagName: 'DIV' }), 'ok'));The threshold: [exit, enter] array is the part most blog posts get wrong. IntersectionObserver only fires when the ratio crosses one of the values you pass, so if you give it [0.5] you do not get sub-half ratios in the entry. We need both 0.3 and 0.6 in the array so the callback runs at both edges, and then the stepHysteresis decision happens in JS. Stashing visible in a ref alongside the React state lets the callback compute the next value without going through a stale closure. I default enter to 0.6 and exit to 0.3 because that is what kept the analytics dashboard quiet in production.
// Common pattern: track a 'card_viewed' event the first time a card crosses
// the enter threshold, never again. Hysteresis still applies for fade-in animations.
const { useState, useCallback, useRef, useEffect } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useRef: (init) => ({ current: init }),
useEffect: () => {},
});
function stepHysteresis(visible, ratio, enter, exit) {
if (!visible && ratio >= enter) return true;
if (visible && ratio <= exit) return false;
return visible;
}
function useInView(options) {
const enter = (options && options.enter) ?? 0.6;
const exit = (options && options.exit) ?? 0.3;
const triggerOnce = !!(options && options.triggerOnce);
const onEnter = options && options.onEnter;
const [visible, setVisible] = useState(false);
const visibleRef = useRef(false);
const firedRef = useRef(false);
const obsRef = useRef(null);
const setRef = useCallback((node) => {
if (obsRef.current) { obsRef.current.disconnect(); obsRef.current = null; }
if (!node || typeof IntersectionObserver === 'undefined') return;
const o = new IntersectionObserver((entries) => {
const e = entries[0]; if (!e) return;
const next = stepHysteresis(visibleRef.current, e.intersectionRatio, enter, exit);
if (next === visibleRef.current) return;
visibleRef.current = next;
setVisible(next);
if (next && !firedRef.current) {
firedRef.current = true;
if (onEnter) onEnter();
if (triggerOnce && obsRef.current) {
obsRef.current.disconnect();
obsRef.current = null;
}
}
}, { threshold: [exit, enter] });
o.observe(node);
obsRef.current = o;
}, [enter, exit, triggerOnce, onEnter]);
useEffect(() => () => { if (obsRef.current) obsRef.current.disconnect(); }, []);
return [setRef, visible];
}
// Simulate the lifecycle by calling onEnter directly through the demo.
let trackCalls = 0;
function trackCardViewed() { trackCalls++; console.log('analytics: card_viewed (call', trackCalls, ')'); }
const [setRef, visible] = useInView({ enter: 0.6, exit: 0.3, triggerOnce: true, onEnter: trackCardViewed });
console.log('initial visible:', visible);
setRef({ tagName: 'DIV' });
console.log('setRef wired with onEnter; in a real DOM, the analytics call would fire once');
console.log('total simulated track() calls (no real DOM yet):', trackCalls);triggerOnce is the option I use most: track a card view the first time it appears, then disconnect the observer entirely so the browser stops doing layout work for it. The two refs do separate jobs. visibleRef mirrors React state so the next frame computes correctly; firedRef is the latch that prevents re-firing if a parent layout shift causes an unmount/remount cycle. Splitting them is the difference between one analytics event per user and one event per scroll, which is what made me write this hook the same week our PostHog bill doubled.
