Troubleshooting React-PDF with IntersectionObserver on Multi-Page PDFs

Troubleshooting React-PDF with IntersectionObserver on Multi-Page PDFs

When a PDF has several pages, an IntersectionObserver can appear to work for the first page but not the rest. It may also report the wrong page, trigger repeatedly, or never fire for a page that you expect to be visible.

These symptoms do not point to one guaranteed cause. The observer watches DOM elements, not PDF page numbers. To diagnose the problem, first confirm that every rendered page has a distinct element and that the observer is watching those elements inside the correct scroll container.

What the observer should be watching

React-PDF renders pages as React components. An observer needs a DOM element to observe, such as a wrapper around each Page. Keep the responsibilities separate:

  • React-PDF renders the document and its pages.
  • Your component assigns each page a stable identity and a DOM node.
  • IntersectionObserver reports when those nodes intersect its configured root.

A common mistake is observing the same element for every page, or attaching the ref to a component without confirming which DOM element the ref reaches. A plain wrapper avoids that ambiguity.

Minimal example

The following example shows the structure of an observer for a scrollable PDF. It is an illustrative example, not a claim that it has been run against a particular React-PDF version. Install and configure React-PDF according to the official documentation for the version in your project.

import { useEffect, useRef, useState } from "react";
import { Document, Page } from "react-pdf";

function PdfViewer({ file }) {
  const viewerRef = useRef(null);
  const [pageCount, setPageCount] = useState(0);
  const [visiblePage, setVisiblePage] = useState(null);

  useEffect(() => {
    const root = viewerRef.current;
    if (!root || pageCount === 0) return;

    const pages = root.querySelectorAll("[data-pdf-page]");

    const observer = new IntersectionObserver(
      (entries) => {
        const visibleEntries = entries
          .filter((entry) => entry.isIntersecting)
          .sort(
            (a, b) =>
              b.intersectionRatio - a.intersectionRatio
          );

        if (visibleEntries.length > 0) {
          setVisiblePage(
            Number(visibleEntries[0].target.dataset.pageNumber)
          );
        }
      },
      {
        root,
        threshold: [0, 0.25, 0.5, 0.75, 1],
      }
    );

    pages.forEach((page) => observer.observe(page));

    return () => observer.disconnect();
  }, [pageCount]);

  return (
    <div
      ref={viewerRef}
      style={{ height: 600, overflowY: "auto" }}
    >
      <Document
        file={file}
        onLoadSuccess={({ numPages }) => setPageCount(numPages)}
      >
        {Array.from({ length: pageCount }, (_, index) => {
          const pageNumber = index + 1;

          return (
            <div
              key={pageNumber}
              data-pdf-page
              data-page-number={pageNumber}
            >
              <Page pageNumber={pageNumber} />
            </div>
          );
        })}
      </Document>

      <p>Visible page: {visiblePage ?? "none"}</p>
    </div>
  );
}

This example observes wrappers rather than relying on a ref passed through the PDF page component. Its observer root is the scrollable viewer, so intersection is measured against that container. If your viewer scrolls as part of the document instead, use the appropriate root for that layout.

Likely causes and how to distinguish them

1. The pages are not in the DOM when observation begins

Pages may be rendered only after the document loads. If the effect runs before the page wrappers exist, a one-time query can return no targets. In the example, the effect depends on pageCount, which changes when the document reports its page count.

If your app renders pages later for another reason, make sure observation is set up after those elements are added. Do not assume that a successful document load means every page wrapper is already present.

2. The observer is watching the wrong element

Check that each page has its own wrapper and that the selector finds all of them. If every page uses the same ID, or a ref is repeatedly assigned to a single variable, you may end up observing only one node.

Use a unique key for each React list item, and use a data attribute or another selector that identifies page wrappers without requiring duplicate IDs.

3. The root does not match the scrolling layout

When root is an element, that element should be the relevant scroll container. If the PDF scrolls inside a fixed-height viewer but the observer uses the default viewport root, reported intersections may not match what the user sees inside that viewer.

Conversely, do not set a custom root just because the PDF is inside a wrapper. Confirm that the wrapper is actually the element whose scrolling defines visibility.

4. The page layout changes after observation starts

Page dimensions can change as content and styles settle. A layout change can affect when an element crosses the observer’s thresholds. Avoid treating one callback as a permanent “current page” answer: callbacks describe intersection changes, and more than one page can intersect at the same time.

If several pages are visible, choose a rule that suits the interface. The example selects the intersecting page with the largest intersectionRatio. For a reading indicator, you might instead select the page nearest the top of the viewer. These are application decisions, not guaranteed observer behaviour.

5. The effect uses stale or incomplete dependencies

React effects should reflect the values they use and should clean up external subscriptions. Here, the effect disconnects its observer before setting up a replacement. If your implementation also reads a changing root, a changing page list, or changing options, account for those values in the effect’s setup and dependencies.

React’s official learning material explains effects and synchronising with external systems. Follow the guidance for the React version used by your project rather than copying an effect pattern without checking how its dependencies are managed.

Safe diagnostic steps

  1. Confirm the page elements exist. Inspect the rendered DOM and check that every expected page has a separate wrapper.
  2. Count the observer targets. Temporarily log pages.length after the query. Compare it with the number of page wrappers actually rendered.
  3. Check the root. Confirm that the selected root is the element that scrolls. If the document itself scrolls, test the default viewport root instead.
  4. Log callback entries. For each entry, inspect its target’s page number, isIntersecting, and intersectionRatio. This helps separate a missing target from a threshold or layout issue.
  5. Check for repeated setup. Make sure the effect disconnects the old observer and does not accidentally observe duplicate wrappers.
  6. Test with a simple layout. Temporarily remove unrelated positioning and transforms from the viewer and page wrappers. Add them back one at a time if the observer starts behaving as expected.

These checks are diagnostic, not a substitute for inspecting your application’s actual DOM and layout. The supplied issue link is a topic reference; it does not establish which cause applies to your code.

Version-aware checks

The example uses the public Document and Page component names and a document-load callback, but exact props, worker setup, and rendering details can depend on the React-PDF version in use. Check the documentation and migration notes for your installed version before changing PDF configuration.

Likewise, use the React documentation for your project’s React version when checking effect behaviour. This guide does not assume a specific React-PDF or React release, and it does not claim that the sample code has been tested against a particular combination.

How to verify a fix

  • Load a document with several pages and confirm that the DOM contains a distinct wrapper for each rendered page.
  • Scroll the PDF from top to bottom and check that callback entries refer to the page wrappers, not the outer document container.
  • Check behaviour when two pages are partly visible. Confirm that your chosen rule produces the intended page indicator.
  • Reload or replace the PDF and verify that the observer is recreated for the new page elements.
  • Inspect the console and application state for errors, but do not treat the absence of errors as proof that the observer is watching the correct targets.

Common follow-up questions

Why do I get more than one visible page?

Because a viewport or scroll container can show parts of multiple pages at once. Decide what “current page” means for your interface, such as the page with the greatest visible ratio or the page nearest the top. Do not assume the observer will return exactly one visible element.

Should I observe the React-PDF component directly?

You need to observe a DOM element. A wrapper around each page is a straightforward option because your component controls its ref and attributes. If you choose to attach a ref elsewhere, first confirm that it resolves to a DOM node in the React-PDF version you are using.

References

Post a Comment

0 Comments