Skip to content

useIntersection: documented root: ref.current usage is a fragile init-order pattern #9026

Description

@ryami333

What package has an issue

@mantine/hooks (v9.3.1) — useIntersection

Describe the bug

The documented usage example passes root: containerRef.current into the hook:

const containerRef = useRef<HTMLDivElement>(null);
const { ref, entry } = useIntersection({
  root: containerRef.current,
  threshold: 1,
});

return (
  <div ref={containerRef} style={{ overflowY: "scroll", height: 300 }}>
    <div ref={ref}></div>
  </div>
);

containerRef.current is read during render, where it is null on the first render (the container hasn't committed yet), and a ref is not reactive — populating .current does not trigger a re-render. So the observer is initially created against the wrong root.

Why this is fragile

Walking through the hook (use-intersection.ts), the returned callback ref is memoized via useCallback keyed on [options?.rootMargin, options?.root, options?.threshold], and it constructs the IntersectionObserver with options when the element attaches:

  1. Render 1: containerRef.current is nulluseIntersection({ root: null }). Callback ref memoized with root: null.
  2. Commit: the inner ref attaches → an observer is created with root: null (the viewport), not the intended container.
  3. IntersectionObserver fires once after observe()setEntry(...) → triggers a re-render.
  4. Render 2: now containerRef.current is the element, so options.root changed → useCallback returns a new ref identity → React detaches the old observer and creates a second observer with the correct container root.

So it only self-corrects because setEntry happens to force a re-render. The cost is a throwaway viewport-rooted observer, an extra render, and a brief entry: null flicker. More importantly, the self-heal only approximates the right behavior while the container is fully within the viewport — if the container is larger than the viewport, partially scrolled off-screen, or if the corrective re-render is suppressed, the result keys off the viewport instead of the intended container.

This is the same root cause as #9025 (useScrollSpy scrollHost): the hook wants a resolved HTMLElement for root, but the idiomatic source is a ref, and the documented pattern papers over the gap by reading .current during render.

Proposed fix

Two parts, either/both:

  1. Docs: update the usage example to drive the container via state (a callback ref into useState), which is deterministic and avoids the throwaway observer:

    const [container, setContainer] = useState<HTMLDivElement | null>(null);
    const { ref, entry } = useIntersection({ root: container, threshold: 1 });
    
    return (
      <div ref={setContainer} style={{ overflowY: "scroll", height: 300 }}>
        <div ref={ref}></div>
      </div>
    );
  2. API (optional, mirrors useScrollSpy: accept a RefObject<HTMLElement> for scrollHost, not just a resolved HTMLElement #9025): allow root to accept a ref object and resolve it internally:

    root?: Element | Document | RefObject<Element | null> | null;

    This is backwards-compatible and lets callers pass the ref they already have without the state dance.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions