Created
July 25, 2025 15:49
-
-
Save david-arteaga/6602869d24a00544c44c9a8ca86928cc to your computer and use it in GitHub Desktop.
[react-router 7 Data mode NavLink with prefetch implemented] Sadly `prefetch` is not supported in react-router v7 Data mode (only Framework mode), so this component implements it #react-router #prefetch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback, useEffect, useRef } from 'react'; | |
import { NavLink, useFetcher } from 'react-router-dom'; | |
import mergeRefs from '../utils/merge-refs'; | |
type PrefetchOption = 'intent' | 'render' | 'viewport'; | |
interface PrefetchLinkProps extends React.ComponentProps<typeof NavLink> { | |
prefetch?: PrefetchOption; | |
} | |
// If prefetch === 'viewport', start prefetching when element is these many pixels away from the viewport | |
const VIEWPORT_PREFETCH_MARGIN_PX = 50; | |
export function PrefetchLink(props: PrefetchLinkProps) { | |
const prefetch = props.prefetch ?? 'intent'; | |
const fetcher = useFetcher(); | |
const linkRef = useRef<HTMLAnchorElement>(null); | |
const hasViewportPrefetched = useRef(false); | |
const onIntent = useCallback(() => { | |
if (prefetch === 'intent') { | |
fetcher.load(props.to as string); | |
} | |
}, [fetcher, props.to, prefetch]); | |
// Handle render prefetch | |
useEffect(() => { | |
if (prefetch === 'render') { | |
fetcher.load(props.to as string); | |
} | |
}, [fetcher, props.to, prefetch]); | |
// Handle viewport prefetch | |
useEffect(() => { | |
if (prefetch !== 'viewport' || hasViewportPrefetched.current) { | |
return; | |
} | |
const element = linkRef.current; | |
if (!element) return; | |
const observer = new IntersectionObserver( | |
(entries) => { | |
const [entry] = entries; | |
if (entry.isIntersecting && !hasViewportPrefetched.current) { | |
hasViewportPrefetched.current = true; | |
fetcher.load(props.to as string); | |
observer.disconnect(); | |
} | |
}, | |
{ | |
rootMargin: `${VIEWPORT_PREFETCH_MARGIN_PX}px`, | |
} | |
); | |
observer.observe(element); | |
return () => { | |
observer.disconnect(); | |
hasViewportPrefetched.current = false; | |
}; | |
}, [fetcher, props.to, prefetch]); | |
return ( | |
<NavLink | |
{...props} | |
ref={mergeRefs(linkRef, props.ref)} | |
onMouseEnter={useConsolidateCbs(onIntent, props.onMouseEnter)} | |
onFocus={useConsolidateCbs(onIntent, props.onFocus)} | |
onTouchStart={useConsolidateCbs(onIntent, props.onTouchStart)} | |
/> | |
); | |
} | |
function useConsolidateCbs<F extends (...args: any[]) => void>( | |
...cbs: (F | undefined)[] | |
) { | |
return useCallback((...args: Parameters<F>) => { | |
cbs.forEach((cb) => cb?.(...args)); | |
}, cbs); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment