blog.itcode.devblog.itcode.dev

[React Component] Building an Infinite Scroll Component

Let's implement an Infinite Scroll component from scratch in a vanilla React.js environment

[React Component] Building an Infinite Scroll Component

Let's implement an Infinite Scroll component from scratch in a vanilla React.js environment
RWB0104
@RWBwritten at 2024-07-22 12:43:03

The blog went into a hiatus for a fairly long stretch of time. Of course, I wasn't taking a break from work during that time, so I wasn't away from development.

Thanks to that, I was able to build components both large and small, and among them, I'd like to pick out the ones that see a lot of use and organize them into this series.

As the first entry, I'd like to cover Infinite Scroll, which is widely used in things like boards or lists.

Before building the component, let's lay out its requirements.

  1. When the component is scrolled down to the last position, this should be detected and trigger the desired action.

The requirement is as simple as that. The key phrase is detecting the last position, and implementing that is the crux of the matter.

Let's name the component we'll build InfiniteScroll, and implement the InfiniteScroll component based on the requirement above.

Let's implement the basic layout of InfiniteScroll.

TSX

export default function InfiniteScroll(): JSX.Element
{
	return (
		<div />
	)
}

Declare the basic foundational layout div.

Let's implement the interface the InfiniteScroll component will receive.

TSX

export type InfiniteScrollEndHandler = () => void;

export interface InfiniteScrollProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
{
	/**
	 * Whether it is disabled
	 */
	disabled?: boolean;

	/**
	 * Scroll-end event method
	 */
	onEnd?: InfiniteScrollEndHandler;
}

export default function InfiniteScroll({ disabled, onEnd, children, ...props }: InfiniteScrollProps): JSX.Element
{
	return (
		<div {...props}>
			{children}
		</div>
	)
}

The custom properties the InfiniteScroll component will receive are disabled and the onEnd method. The role of each property is as described in the comments.

Additionally, by extending the DetailedHTMLProps interface, InfiniteScroll is set up to accept all the default div properties as well.

We'll configure it so that onEnd runs the moment the scroll reaches the end of the component. If disabled is true, it will be treated as disabled and the action will be blocked.

Let's implement the core logic — the scroll detection logic.

You'd typically think of a scroll event based on document.addEventListener, but this approach has too much overhead.

There's the issue that attaching an event to document causes the scope to extend outside the component, and it's also poor from an optimization standpoint since the event logic runs on every single scroll.

If you actually implement it this way, you'll find you need to add a fair amount of unnecessary code compared to the Observer approach described below.


To implement this logic, we'll make use of IntersectionObserver, which we covered previously.

For details on the browser's Observer API, IntersectionObserver, refer to [Deep Dive into the Observer API] 2. IntersectionObserver on this blog.

The principle is as follows.

  1. Place a dummy component for position detection at the very bottom, inside InfiniteScroll.
  2. Use IntersectionObserver to determine whether the dummy component is visible to the user.
  3. If the dummy component becomes visible, interpret this as the scroll having reached the end, and run the onEnd method.
  4. If disabled is true, the dummy component is not created, which preemptively prevents the action from firing.

TSX

import { useIntersectionObserver } from '@kapoo/common';
import { useState, DetailedHTMLProps, HTMLAttributes } from 'react';

export type InfiniteScrollEndHandler = () => void;

export interface InfiniteScrollProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
{
	/**
	 * Whether it is disabled
	 */
	disabled?: boolean;

	/**
	 * Scroll-end event method
	 */
	onEnd?: InfiniteScrollEndHandler;
}

export default function InfiniteScroll({ disabled, onEnd, children, ...props }: InfiniteScrollProps): JSX.Element
{
	const [ domState, setDomState ] = useState<HTMLDivElement | null>(null);

	useIntersectionObserver(domState, (entry) =>
	{
		// If the DOM is visible
		if (entry.isIntersecting)
		{
			onEnd?.();
		}
	});

	return (
		<div {...props}>
			{children}

			{children && !disabled ? <div ref={setDomState} style={{ width: '100%' }} /> : null}
		</div>
	)
}

useIntersectionObserver is not an external library, but code implemented directly within the project, as shown below.

TYPESCRIPT

import { useEffect } from "react";

export type UseIntersectionObserverCallback = (
  entry: IntersectionObserverEntry
) => void;

/**
 * Hook method for applying IntersectionObserver
 *
 * @param {Element | string | null} ref: Element
 * @param {UseIntersectionObserverCallback} callback: Callback method
 * @param {IntersectionObserverInit} options: Options
 */
export function useIntersectionObserver(
  ref: Element | string | null,
  callback: UseIntersectionObserverCallback,
  options?: IntersectionObserverInit
): void
{
  useEffect(() => {
    const io = new IntersectionObserver((entries) => {
      entries.forEach(callback);
    }, options);

    // If the DOM is valid
    if (ref) {
      // If ref is a string
      if (typeof ref === "string") {
        const tag = document.querySelector(ref);

        // If the tag is valid
        if (tag) {
          io.observe(tag);
        }
      }

      // If it is a DOM element
      else {
        io.observe(ref);
      }
    }

    return () => {
      io.disconnect();
    };
  }, [ref, callback, options]);
}

Whether children is valid is also factored into displaying the dummy component, as a measure to prevent duplicate triggering.

On the component's initial render, if the dummy component is exposed at a moment when data hasn't been drawn yet, the onEnd method would run unintentionally.

To prevent this, this is a safeguard configured so that the event is only requested after a valid component has actually rendered.

The code above works well enough on its own, but there can be cases where it falls a bit short depending on the situation.

When a user reaches the end of the page, the next batch of data is fetched at that moment (typically via an API). This process — fetching the data, processing it, and rendering it — takes a fairly non-trivial amount of time.

To improve the user's UX, what if we set the trigger point slightly above where the scroll actually ends?

It would appear to the user's eyes as though they can view the list continuously, without any loading interruption.

This can be implemented using IntersectionObserver's rootMargin option.

TSX

import { useIntersectionObserver } from '@kapoo/common';
import { useState, DetailedHTMLProps, HTMLAttributes } from 'react';

export type InfiniteScrollEndHandler = () => void;

export interface InfiniteScrollProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
{
	/**
	 * Whether it is disabled
	 */
	disabled?: boolean;

	/**
	 * Margin
	 */
	rootMargin?: string;

	/**
	 * Scroll-end event method
	 */
	onEnd?: InfiniteScrollEndHandler;
}

export default function InfiniteScroll({ disabled, rootMargin, onEnd, children, ...props }: InfiniteScrollProps): JSX.Element
{
	const [ domState, setDomState ] = useState<HTMLDivElement | null>(null);

	useIntersectionObserver(domState, (entry) =>
	{
		// If the DOM is visible
		if (entry.isIntersecting)
		{
			onEnd?.();
		}
	}, { rootMargin });

	return (
		<div {...props}>
			{children}

			{children && !disabled ? <div ref={setDomState} style={{ width: '100%' }} /> : null}
		</div>
	)
}

We now additionally receive rootMargin and pass it to useIntersectionObserver. You can specify it the same way as the CSS margin property.

TSX

import { useIntersectionObserver } from '@kapoo/common';
import { useState, DetailedHTMLProps, HTMLAttributes } from 'react';

export type InfiniteScrollEndHandler = () => void;

export interface InfiniteScrollProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>
{
	/**
	 * Whether it is disabled
	 */
	disabled?: boolean;

	/**
	 * Margin
	 */
	rootMargin?: string;

	/**
	 * Scroll-end event method
	 */
	onEnd?: InfiniteScrollEndHandler;
}

export default function InfiniteScroll({ disabled, rootMargin, onEnd, children, ...props }: InfiniteScrollProps): JSX.Element
{
	const [ domState, setDomState ] = useState<HTMLDivElement | null>(null);

	useIntersectionObserver(domState, (entry) =>
	{
		// If the DOM is visible
		if (entry.isIntersecting)
		{
			onEnd?.();
		}
	}, { rootMargin });

	return (
		<div {...props}>
			{children}

			{children && !disabled ? <div ref={setDomState} style={{ width: '100%' }} /> : null}
		</div>
	)
}

TYPESCRIPT

import { useEffect } from "react";

export type UseIntersectionObserverCallback = (
  entry: IntersectionObserverEntry
) => void;

/**
 * Hook method for applying IntersectionObserver
 *
 * @param {Element | string | null} ref: Element
 * @param {UseIntersectionObserverCallback} callback: Callback method
 * @param {IntersectionObserverInit} options: Options
 */
export function useIntersectionObserver(
  ref: Element | string | null,
  callback: UseIntersectionObserverCallback,
  options?: IntersectionObserverInit
): void
{
  useEffect(() => {
    const io = new IntersectionObserver((entries) => {
      entries.forEach(callback);
    }, options);

    // If the DOM is valid
    if (ref) {
      // If ref is a string
      if (typeof ref === "string") {
        const tag = document.querySelector(ref);

        // If the tag is valid
        if (tag) {
          io.observe(tag);
        }
      }

      // If it is a DOM element
      else {
        io.observe(ref);
      }
    }

    return () => {
      io.disconnect();
    };
  }, [ref, callback, options]);
}

The full code is as above.

Let's check out the component directly in CodeSandbox.

For rootMargin, it doesn't seem to work in the preview environment below, likely due to some internal issue, and it seems to work only when viewed in fullscreen.

# TypeScript# React
ship
blog.itcode.dev

Notes from the π-th Alpaca

7.0.1
Developed by RWB since 2021.057th upgraded at 2026.08