import {
useState,
useEffect,
useRef,
useCallback,
ReactNode,
CSSProperties,
} from 'react';
import { buildRootMargin } from './utils/buildRootMargin';
export { useInfiniteScroll } from './useInfiniteScroll';
export type {
UseInfiniteScrollOptions,
UseInfiniteScrollResult,
} from './useInfiniteScroll';
type Fn = () => any;
export interface Props {
/**
* Total number of items currently rendered. Unlocks the next load when it
* changes. Always pass the length of your full accumulated list, not just
* the most recently fetched page.
*/
dataLength: number;
/**
* Called when the user scrolls near the end of the list. Must append new
* items to your list state (not replace them). The component calls this at
* most once per data load, guarded by an IntersectionObserver sentinel.
*/
next: Fn;
/**
* Whether more data exists to load. When false, the observer stops and
* `endMessage` is shown instead of `loader`.
*/
hasMore: boolean;
/**
* The full accumulated list of items to render. Pass every item loaded so
* far — the component is not paginated internally.
*/
children: ReactNode;
/**
* Element shown while the next page is being fetched (while `hasMore` is
* true and `next` has been triggered).
*/
loader: ReactNode;
/**
* How close to the end of the list before `next` fires.
* - Number 0–1: fraction of container height, e.g. `0.8` triggers at 80%
* scrolled (default).
* - Pixel string: absolute offset, e.g. `"200px"` triggers 200 px before
* the end.
* @default 0.8
*/
scrollThreshold?: number | string;
/** Shown below the list once `hasMore` is false. */
endMessage?: ReactNode;
/** Inline styles applied to the inner scroll container. */
style?: CSSProperties;
/**
* Fixed height for the scroll container. When provided, a scrollable box
* of this height is rendered. Omit to scroll the window (document body).
*/
height?: number | string;
/**
* A scrollable parent element that already provides overflow scrollbars.
* Accepts a DOM element reference or the element's string `id`. Pass this
* instead of `height` when the scroll container is owned by the parent.
*
* @example
* // string id
* scrollableTarget="scrollableDiv"
*
* @example
* // ref value
* const ref = useRef(null);
*
*/
scrollableTarget?: HTMLElement | string | null;
/**
* Set to `true` when `children` is not a plain array (e.g. a single node
* or a fragment). Prevents the component from treating a length of 0 as
* "no items loaded".
*/
hasChildren?: boolean;
/**
* Reverse the scroll direction: the sentinel is placed at the top of the
* list and `next` loads older content upward. Use with
* `flexDirection: 'column-reverse'` on the scroll container for chat or
* messaging UIs.
* @default false
*/
inverse?: boolean;
/**
* Enable pull-down-to-refresh on touch and mouse. Requires
* `refreshFunction` to be provided.
* @default false
*/
pullDownToRefresh?: boolean;
/** Content shown while the user is pulling down. @default Pull down to refresh
*/
pullDownToRefreshContent?: ReactNode;
/** Content shown when the pull threshold is breached. @default Release to refresh
*/
releaseToRefreshContent?: ReactNode;
/**
* Minimum pixels the user must pull before `refreshFunction` fires.
* @default 100
*/
pullDownToRefreshThreshold?: number;
/**
* Called when the pull-to-refresh threshold is breached. Should reload or
* reset the list to fresh data.
*/
refreshFunction?: Fn;
/** Called on every scroll event of the scroll container. */
onScroll?: (e: UIEvent) => any;
/** Scroll Y position (in pixels) to restore when the component mounts. */
initialScrollY?: number;
/** CSS class name added to the inner scroll container element. */
className?: string;
}
export default function InfiniteScroll({
next,
hasMore,
children,
loader,
scrollThreshold = 0.8,
endMessage,
style,
height,
scrollableTarget,
hasChildren,
inverse = false,
pullDownToRefresh = false,
pullDownToRefreshContent,
releaseToRefreshContent,
pullDownToRefreshThreshold = 100,
refreshFunction,
onScroll,
dataLength,
initialScrollY,
className = '',
}: Props) {
const [showLoader, setShowLoader] = useState(false);
const [pullToRefreshThresholdBreached, setPullToRefreshThresholdBreached] =
useState(false);
// State drives the JSX re-render when height is measured; ref is read by handlers
const [maxPullDownDistance, setMaxPullDownDistance] = useState(0);
const infScrollRef = useRef(null);
const sentinelRef = useRef(null);
const pullDownRef = useRef(null);
// --- Stable callback refs ---
// Assigned synchronously every render so effects and event handlers always call
// the latest version without adding the functions to any effect dependency array.
// This prevents the IO observer and PTR listeners from being recreated every time
// consumers pass inline functions (the most common real-world usage).
const nextRef = useRef(next);
nextRef.current = next;
const refreshFunctionRef = useRef(refreshFunction);
refreshFunctionRef.current = refreshFunction;
// Ref for pullDownToRefreshThreshold so onMove always reads the current value
// without needing it in the PTR effect deps
const pullThresholdRef = useRef(pullDownToRefreshThreshold);
pullThresholdRef.current = pullDownToRefreshThreshold;
// --- Mutable refs — never trigger re-renders ---
const actionTriggeredRef = useRef(false);
const draggingRef = useRef(false);
const startYRef = useRef(0);
const currentYRef = useRef(0);
const maxPullDownDistanceRef = useRef(0); // kept in sync with maxPullDownDistance state
// Resolve the custom scrollable element; stable as long as scrollableTarget doesn't change
const getScrollableNode = useCallback((): HTMLElement | null => {
if (scrollableTarget instanceof HTMLElement) return scrollableTarget;
if (typeof scrollableTarget === 'string') {
return document.getElementById(scrollableTarget);
}
if (scrollableTarget === null) {
console.warn(
`You are trying to pass scrollableTarget but it is null. This might
happen because the element may not have been added to DOM yet.
See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info.
`
);
}
return null;
}, [scrollableTarget]);
// Effect 1 — one-time validation and initialScrollY
useEffect(() => {
if (typeof dataLength === 'undefined') {
throw new Error(
`mandatory prop "dataLength" is missing. The prop is needed` +
` when loading more content. Check README.md for usage`
);
}
if (pullDownToRefresh && typeof refreshFunction !== 'function') {
throw new Error(
`Mandatory prop "refreshFunction" missing.
Pull Down To Refresh functionality will not work
as expected. Check README.md for usage'`
);
}
if (typeof initialScrollY === 'number') {
const el = height ? infScrollRef.current : getScrollableNode();
if (el && el.scrollHeight > initialScrollY) {
el.scrollTo(0, initialScrollY);
}
}
}, []);
// Effect 2a — reset the load guard when new data arrives.
// Deliberately decoupled from the IO observer (Effect 2b) so the observer
// is NOT recreated on every data load — it lives for the component's full
// mount lifetime and only reconnects when structural config changes.
useEffect(() => {
actionTriggeredRef.current = false;
setShowLoader(false);
}, [dataLength]);
// Effect 2b — IntersectionObserver lifecycle.
// dataLength is intentionally absent from deps: the guard reset above handles
// the per-load reset. The observer only reconnects when the root, margin, or
// direction changes — typically never after initial mount.
useEffect(() => {
if (!hasMore) return;
if (typeof IntersectionObserver === 'undefined') return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const root: Element | null = height
? infScrollRef.current
: getScrollableNode();
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || actionTriggeredRef.current) return;
actionTriggeredRef.current = true;
setShowLoader(true);
nextRef.current(); // stable ref — safe to call without listing next in deps
},
{
root,
rootMargin: buildRootMargin(scrollThreshold, inverse),
threshold: 0,
}
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, scrollThreshold, inverse, height, getScrollableNode]);
// Effect 3 — onScroll passthrough (only when prop is provided)
useEffect(() => {
if (!onScroll) return;
const scrollEl: HTMLElement | Window | null =
(height ? infScrollRef.current : getScrollableNode()) ??
(typeof window !== 'undefined' ? window : null);
if (!scrollEl) return;
const handler = (e: Event) => {
setTimeout(() => onScroll(e as UIEvent), 0);
};
scrollEl.addEventListener('scroll', handler as EventListener);
return () =>
scrollEl.removeEventListener('scroll', handler as EventListener);
}, [onScroll, height, getScrollableNode]);
// Effect 4 — Pull-to-refresh event listeners.
// refreshFunction and pullDownToRefreshThreshold are intentionally absent from
// deps — they are read via refs so listener re-registration is not needed when
// consumers pass new function references or change the threshold at runtime.
useEffect(() => {
if (!pullDownToRefresh) return;
const scrollEl: HTMLElement | Window | null =
(height ? infScrollRef.current : getScrollableNode()) ??
(typeof window !== 'undefined' ? window : null);
if (!scrollEl) return;
// Measure pull-down indicator height after mount
if (pullDownRef.current?.firstChild) {
const dist = (
pullDownRef.current.firstChild as HTMLElement
).getBoundingClientRect().height;
maxPullDownDistanceRef.current = dist;
setMaxPullDownDistance(dist);
}
const onStart = (evt: Event) => {
// Only allow pull-to-refresh when the scroll container is at the very top.
// Replaces the old lastScrollTop ref which was never updated after removing
// the scroll event listener, making the original guard permanently a no-op.
const scrollTop =
scrollEl instanceof HTMLElement
? scrollEl.scrollTop
: document.documentElement.scrollTop;
if (scrollTop > 0) return;
draggingRef.current = true;
if (evt instanceof MouseEvent) {
startYRef.current = evt.pageY;
} else if (evt instanceof TouchEvent) {
startYRef.current = evt.touches[0].pageY;
}
currentYRef.current = startYRef.current;
if (infScrollRef.current) {
infScrollRef.current.style.willChange = 'transform';
infScrollRef.current.style.transition =
'transform 0.2s cubic-bezier(0,0,0.31,1)';
}
};
const onMove = (evt: Event) => {
if (!draggingRef.current) return;
if (evt instanceof MouseEvent) {
currentYRef.current = evt.pageY;
} else if (evt instanceof TouchEvent) {
currentYRef.current = evt.touches[0].pageY;
}
// user is scrolling up — ignore
if (currentYRef.current < startYRef.current) return;
const delta = currentYRef.current - startYRef.current;
// Read via ref — no stale closure risk, no effect re-registration needed
if (delta >= pullThresholdRef.current) {
setPullToRefreshThresholdBreached(true);
}
// limit drag to 1.5x maxPullDownDistance
if (delta > maxPullDownDistanceRef.current * 1.5) return;
if (infScrollRef.current) {
infScrollRef.current.style.overflow = 'visible';
infScrollRef.current.style.transform = `translate3d(0px, ${delta}px, 0px)`;
}
};
const onEnd = () => {
startYRef.current = 0;
currentYRef.current = 0;
draggingRef.current = false;
setPullToRefreshThresholdBreached((breached) => {
if (breached) {
// Read via ref — refreshFunction identity changes don't re-register listeners
refreshFunctionRef.current?.();
}
return false;
});
requestAnimationFrame(() => {
if (infScrollRef.current) {
infScrollRef.current.style.overflow = 'auto';
infScrollRef.current.style.transform = 'none';
infScrollRef.current.style.willChange = 'unset';
}
});
};
scrollEl.addEventListener('touchstart', onStart as EventListener);
scrollEl.addEventListener('touchmove', onMove as EventListener);
scrollEl.addEventListener('touchend', onEnd as EventListener);
scrollEl.addEventListener('mousedown', onStart as EventListener);
scrollEl.addEventListener('mousemove', onMove as EventListener);
scrollEl.addEventListener('mouseup', onEnd as EventListener);
return () => {
scrollEl.removeEventListener('touchstart', onStart as EventListener);
scrollEl.removeEventListener('touchmove', onMove as EventListener);
scrollEl.removeEventListener('touchend', onEnd as EventListener);
scrollEl.removeEventListener('mousedown', onStart as EventListener);
scrollEl.removeEventListener('mousemove', onMove as EventListener);
scrollEl.removeEventListener('mouseup', onEnd as EventListener);
};
}, [pullDownToRefresh, height, getScrollableNode]);
const containerStyle: CSSProperties = {
height: height ?? 'auto',
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
...style,
};
const hasChildrenResolved =
hasChildren || !!(children && children instanceof Array && children.length);
const outerDivStyle: CSSProperties =
pullDownToRefresh && height ? { overflow: 'auto' } : {};
const sentinel = hasMore ? (
) : null;
return (
{pullDownToRefresh && (
{pullToRefreshThresholdBreached
? releaseToRefreshContent
: pullDownToRefreshContent}
)}
{children}
{!showLoader && !hasChildrenResolved && hasMore && loader}
{showLoader && hasMore && loader}
{sentinel}
{!hasMore && endMessage}
);
}