custom hooks

useIntersectionObserver( )

The code provided in this article is simplified as much as possible to avoid overwhelming the reader with code and to convey the essence of the problem and its solution. In interactive examples, heading tags (`h1...h6`) are replaced with span tags to prevent potential conflicts with the main page structure.

Explanation

The custom hook useIntersectionObserver facilitates the observation of element visibility in React components through the IntersectionObserver API. It not only detects when a specified DOM element enters or exits the viewport but also maintains a state variable isIntersecting, reflecting the current visibility status of the target element. This state variable allows React components to respond dynamically to changes in element visibility, enabling the execution of tailored actions based on whether the element is intersecting with the viewport or not.

Additionally, useIntersectionObserver supports an optional callback parameter, allowing developers to define custom logic to be executed precisely when the target element becomes intersecting. This callback function, if provided, serves as a flexible mechanism for triggering specific behaviors or side effects in response to element visibility changes. Whether it's prefetching content, loading additional data, or animating elements, the callback empowers developers to implement a wide range of dynamic interactions seamlessly within their React applications.

Furthermore, useIntersectionObserver offers flexibility through the optional parameter options, which enables customization of the Intersection Observer's behavior. Developers can pass various options supported by the Intersection Observer API, such as the threshold to specify at what percentage of visibility the callback should be triggered or rootMargin to define the margins around the root element for intersection calculations. This level of configurability empowers developers to fine-tune the intersection detection process according to their specific use cases, ensuring optimal performance and responsiveness in their React applications.

With its ability to encapsulate intersection observation logic, manage visibility state, and offer customizable callback functionality, useIntersectionObserver streamlines the development of responsive and interactive user interfaces in React. Its versatility and ease of use make it an invaluable tool for enhancing user experience and optimizing performance across various web applications and components.

Usage Cases

  • Lazy Loading of Images: Automatically load images as they come into the viewport, improving initial load time and conserving bandwidth.
  • Infinite Scrolling: Dynamically load more content (e.g., blog posts, comments) as the user scrolls to the bottom of a list, enhancing the user experience by avoiding page reloads.
  • Animations on Scroll: Trigger animations or transitions when an element enters the viewport, creating engaging visual effects as users scroll through the page.
  • Prefetching Data: Preload data or resources when an element is about to come into view, ensuring smoother and faster interactions once the user reaches that part of the page.
  • Sticky Headers: Activate sticky headers or elements when they intersect with the top of the viewport, providing contextual information or navigation aids as users scroll.
  • Tracking Element Visibility: Monitor the visibility of key elements (e.g., ads, call-to-action buttons) to gather analytics data on user engagement and interaction with these elements.

Creation

Code implementation
1type UseIntersectionObserverArgs<T> = {
2 element: RefObject<T>;
3 callback?: () => void;
4 parentElement?: RefObject<T | null> | Document;
5 rootMargin?: string;
6 threshold?: number | number[];
7};
8
9const useIntersectionObserver = <T extends HTMLElement>({
10 element,
11 callback,
12 parentElement,
13 rootMargin = '0%',
14 threshold = 0,
15}: UseIntersectionObserverArgs<T>): [boolean, () => void] => {
16 // Initialize the state to keep track of whether the target element is intersecting
17 const [isIntersecting, setIsIntersecting] = useState(false);
18
19 // Create a `ref` to store the `IntersectionObserver` instance
20 const observerRef = useRef<IntersectionObserver | null>(null);
21
22 // Create a `ref` to store the latest callback function if provided
23 const latestCallback = useRef(callback ?? null);
24
25 // Update `latestCallback.current` when the callback changes.
26 // This ensures our `useEffect` below always gets the latest callback without
27 // needing to pass it in its dependencies array,
28 // which would cause the `useEffect` to re-run after every component render
29 // in case the callback is created from scratch inside it
30
31 // `useLayoutEffect` is used here to guarantee that the callback
32 // reference is updated before any subsequent renders or event handling take place.
33 // Since there's a possibility that when using `useEffect`, at a certain moment,
34 // the callback might become outdated. This is because `useEffect` is asynchronous:
35 // its code executes after the browser finishes rendering. Consequently,
36 // the callback will be updated after the browser's initial rendering.
37 // In contrast, `useLayoutEffect` is synchronous, ensuring that its code executes
38 // after all DOM mutations but before the browser updates the interface
39 useLayoutEffect(() => {
40 // Check if `callback` provided
41 if (callback) {
42 // Update `latestCallback` when the `callback` (its reference) changes
43 latestCallback.current = callback;
44 }
45 }, [callback]);
46 // By using `useRef` and `useLayoutEffect`, this technique eliminates the need
47 // to memoize the callback function outside the hook (in the component) via `useCallback`.
48 // It abstracts the optimization logic inside the hook, making it more convenient
49
50 // Create a function to stop observing the target element.
51 // Using `unobserve` method stops observing only the specified element,
52 // allowing the observer to potentially observe other elements
53 const unobserve = () => {
54 // If the observer is active and the target element exists, stop observing the element
55 if (observerRef.current && element.current) {
56 observerRef.current.unobserve(element.current);
57 }
58 };
59
60 // Alternative using `disconnect`, which stops observing all elements
61 // const unobserve = () => {
62 // if (observerRef.current) {
63 // observerRef.current.disconnect();
64 // }
65 // };
66
67 useEffect(() => {
68 // Ensure we have a valid `ref` to observe and the browser supports the `IntersectionObserver` API
69 if (!element.current || !('IntersectionObserver' in window)) return;
70
71 // Define the root element for the `IntersectionObserver` based on the `parentElement` prop
72 const root: Element | Document | null = parentElement
73 ? // First of all check if `parentElement` is provided
74 parentElement instanceof Document
75 ? // If `parentElement` is provided and it's an instance of `Document`, use it as the root
76 parentElement
77 : // If `parentElement` is provided and it's a `ref` object, use its current property as the root
78 parentElement?.current
79 : // If not provided, `root` will be null, meaning the viewport will be used as the root
80 null;
81
82 // Create the options object for the `IntersectionObserver`
83 const finalOptions = { threshold, root, rootMargin };
84
85 // Try to create the `IntersectionObserver` instance
86 try {
87 observerRef.current = new IntersectionObserver(([entry]) => {
88 // Update the `isIntersecting` state
89 setIsIntersecting(entry.isIntersecting);
90
91 // Call the latest callback if provided when the target element is intersecting
92 if (entry.isIntersecting && latestCallback.current) {
93 latestCallback.current();
94 }
95 }, finalOptions);
96 } catch (error) {
97 // Handle `IntersectionObserver` creation error
98 console.error('Error creating `IntersectionObserver`:', error);
99
100 // In this example it just logs the error to the console.
101 // Additional error handling logic can be added here as needed
102 }
103
104 // Start observing the element if both `element.current` and `observerRef.current` are defined.
105 // `element.current` ensures that the DOM element is available to observe.
106 // `observerRef.current` ensures that the `IntersectionObserver` instance is successfully created
107 if (element.current && observerRef.current) {
108 observerRef.current.observe(element.current);
109 }
110
111 // Cleanup function to disconnect the observer when the component unmounts
112 // or one of the dependencies changes
113 return () => {
114 // The check for `observerRef.current` ensures that the `IntersectionObserver`
115 // instance exists before attempting to disconnect it
116 if (observerRef.current) {
117 observerRef.current.disconnect();
118 }
119 };
120 }, [element, threshold, parentElement, rootMargin]); // Re-run `useEffect` only when any of these values change
121
122 return [isIntersecting, unobserve];
123};
124
125export default useIntersectionObserver;
126

Reference

Code implementation
1const [isIntersecting, unobserve] = useIntersectionObserver({
2 element, callback?, parentElement?, rootMargin?, threshold?,
3});
4

Parameters

NameTypeDescription
elementRefObject<T>A reference to the DOM element whose visibility needs to be observed.
callback (optional)() => voidAn optional callback function invoked when the element intersects.
parentElement (optional)RefObject<T | null> | DocumentAn optional reference to the parent element or Document object.
rootMargin (optional)stringAn optional value specifying the margins around the root element for the observer.
threshold (optional)number | number[]An optional value representing the percentage of the target's visibility required to trigger the observer's callback.

Return Values

NameTypeDescription
isIntersectingbooleanThe state indicating whether the element intersects with the visible area.
unobserve() => voidA function to stop observing the element.

Example Usages

Lazy Loading of an Image in an Article

This example demonstrates how to use the useIntersectionObserver hook for lazy loading. The hook monitors the visibility of a target element (LazyImage), and when it enters the viewport, it simulates the loading of a heavy image (represented by the Square component). This approach improves performance by deferring content loading until it becomes visible to the user.

Code implementation
1const LazyImage: FC = () => {
2 // Create a `ref` to observe the visibility of the target element
3 const targetRef = useRef<HTMLDivElement | null>(null);
4
5 // Use a custom hook to track if the target element is visible in the viewport
6 const [isIntersecting, unobserve] = useIntersectionObserver({
7 element: targetRef,
8 });
9
10 // Stop observing once the element becomes visible, as the content is now loaded
11 if (isIntersecting) {
12 unobserve();
13 }
14
15 return (
16 <div ref={targetRef}>
17 {isIntersecting ? (
18 // Display the "loaded" content if visible
19 <Square colored>
20 <Shapes className="h-20 w-20" />
21 </Square>
22 ) : (
23 // Otherwise, show a loader
24 <Loader />
25 )}
26 </div>
27 );
28};
29
30const Scrollable: FC = () => {
31 return (
32 <Layer title="Scrollable">
33 <div className="relative border-y-4 border-neutral-900 dark:border-neutral-100">
34 <div className="flex h-80 flex-col space-y-2 overflow-y-auto p-4">
35 <Article />
36 <LazyImage />
37 <Article />
38 </div>
39 </div>
40 </Layer>
41 );
42};
43
An interactive output of the code above

Animated Component

This example demonstrates how to use the useIntersectionObserver hook to conditionally render an animated component. The hook tracks the visibility of a target element (AnimatedComponent) and renders the Loader animation only when the element enters the viewport. This approach optimizes rendering by ensuring the animation is displayed only when it's needed, enhancing both performance and user experience.

Code implementation
1const AnimatedComponent: FC = () => {
2 // Create a `ref` to observe the visibility of the target element
3 const targetRef = useRef<HTMLDivElement | null>(null);
4
5 // Use a custom hook to track if the target element is visible in the viewport
6 const [isIntersecting] = useIntersectionObserver({
7 element: targetRef,
8 });
9
10 return (
11 <div
12 ref={targetRef}
13 className="flex h-40 w-full items-center justify-center"
14 >
15 {isIntersecting && (
16 // Render the animation (`Loader`) only when the target element is visible in the viewport
17 <Loader />
18 )}
19 </div>
20 );
21};
22
23const Scrollable: FC = () => {
24 return (
25 <Layer title="Scrollable">
26 <div className="relative border-y-4 border-neutral-900 dark:border-neutral-100">
27 <div className="flex h-80 flex-col space-y-2 overflow-y-auto p-4">
28 <Article />
29 <AnimatedComponent />
30 <Article />
31 </div>
32 </div>
33 </Layer>
34 );
35};
36
An interactive output of the code above

Infinite Scrolling Todo List

This example demonstrates how to use the useIntersectionObserver hook to implement infinite scrolling with paginated data fetching. A target element is monitored for intersection within a scrollable parent container. When the target enters the viewport, the fetchTodos function is triggered to load the next batch of todos from an API. The fetched items are appended to the existing list, and a loader is displayed during data fetching. Once the final page is reached, the observation stops, preventing further requests. This approach ensures seamless data loading as the user scrolls, enhancing performance and user experience.

Code implementation
1type Todo = {
2 id: string;
3 title: string;
4};
5
6type FetchTodosArgs = {
7 onSuccess: (nextTodos: IItems) => void;
8 params: { page: number };
9};
10
11const initialPage = 1;
12
13const lastPage = 22;
14
15// Create a function to fetch todos from the API
16const fetchTodos = ({ onSuccess, params }: FetchTodosArgs) => {
17 const { page } = params;
18
19 console.log('fetchTodos');
20
21 fetch(`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${page}`)
22 .then((response) => response.json())
23 .then((json) => {
24 const nextTodos: IItems = json.map((todo: Todo) => ({
25 id: todo.id,
26 name: `${todo.id}. ${todo.title}`,
27 }));
28
29 onSuccess(nextTodos);
30 })
31 .catch((error) => {
32 console.error('Error fetching todos:', error);
33 // In this example it just logs the error to the console.
34 // Additional error handling logic can be added here as needed
35 });
36};
37
38const Scrollable: FC = () => {
39 // Initialize states to store the todos
40 const [todos, setTodos] = useState<IItems>([]);
41
42 // Initialize states to store the current page number
43 const [page, setPage] = useState(initialPage);
44
45 // Create `ref` for the parent container
46 const parentRef = useRef<HTMLDivElement | null>(null);
47
48 // Create `ref` for the target element
49 const targetRef = useRef<HTMLDivElement | null>(null);
50
51 // Create a function to handle successful fetching of new todos.
52 // Use `useCallback` to memoize the function to prevent unnecessary re-creations on re-renders
53 const onSuccess = useCallback((newTodos: IItems) => {
54 // Update `todos` state
55 setTodos((prev) => [...prev, ...newTodos]);
56
57 // Increment page number for next fetching
58 setPage((prev) => prev + 1);
59 }, []);
60
61 // Use the custom hook to observe the target element's visibility,
62 // passing `parentElement` and `callback` as arguments to fetch more todos
63 // when the target element is intersecting
64 const [isIntersecting, unobserve] = useIntersectionObserver({
65 element: targetRef,
66 callback: () => {
67 if (page === lastPage) {
68 unobserve();
69 return;
70 }
71
72 fetchTodos({
73 onSuccess,
74 params: {
75 page,
76 },
77 });
78 },
79 parentElement: parentRef,
80 // Intersection threshold for triggering the loading of the next batch of tasks.
81 // This value means fetching will start when 30% of the target element becomes visible
82 threshold: 0.3,
83 });
84
85 return (
86 <Layer title="Scrollable">
87 <div className="relative border-y-4 border-neutral-900 dark:border-neutral-100">
88 <div
89 ref={parentRef}
90 className="flex h-80 flex-col space-y-2 overflow-y-auto p-4"
91 >
92 <List items={todos} />
93 {/* Invisible target element for intersection observation */}
94 <div
95 ref={targetRef}
96 className="invisible"
97 />
98 {/* Conditionally display a loader when the target element is intersecting */}
99 {isIntersecting && page !== lastPage && (
100 <div className="flex w-full items-center justify-center">
101 <Loader />
102 </div>
103 )}
104 </div>
105 </div>
106 </Layer>
107 );
108};
109
An interactive output of the code above
Last updated on by @skrykateSuggest an improvement on Github repository.