Skip to content

use of React 19 ref callbacks for IntersectionObserver tracking #718

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,60 @@ You can read more about this on these links:
- [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372)
- [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465)

### `useOnInViewChanged` hook

```js
const inViewRef = useOnInViewChanged(
(element, entry) => {
// Do something with the element that came into view
console.log('Element is in view', element);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the consumer needs the element, they should be able to get it from entry.target.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the idea - element is gone
This has only one downside - entry might be undefined if initialInView is true:

const inViewRef = useOnInViewChanged(
  (enterEntry) => {
    // Do something with the element that came into view
    console.log('Element is in view', enterEntry?.element);
    
    // Optionally return a cleanup function
    return (exitEntry) => {
      console.log('Element moved out of view or unmounted');
    };
  },
  options // Optional IntersectionObserver options
);

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialInView wouldn't make sense when used with useOnViewChanged anyway - It's for avoiding flashing content on the initial render.

Copy link
Author

@jantimon jantimon Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe the name useOnInViewChanged misleading and should be useOnInViewEntered or useOnInViewEffect

useInView uses useOnInViewChanged and therefore has to pass over the initialInView option - otherwise it is not possible to update the state on out of view

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe changing the api of useOnInViewChanged slightly might get rid of the undefined entry case and handle the initialInVIew better

I'll let you know if it works

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, and I agree with the name. I have been considering building that hook before, but got stuck on the finding the right name. I might be more into useOnInView.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I refactored the code

  • useInView same api like before
  • useOnInView no longer accepts initialInView
  • useOnInView accepts now a trigger option (which is set to enter by default but can be changed to leave):
const trackingRef = useOnInView((entry) => {
    console.log("Element left the view", entry.target);
    return () => {
      console.log("Element entered the view");
    };
  }, {
    trigger: "leave",
  });

that made it way easier to use useOnInView inside useInView for the initialInView case

it also solved the non existing entry case

what do you think?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good - Did you try it with multiple thresholds? Would it just trigger multiple times? Should be fine, as long as it can then be read from the entry

Copy link
Author

@jantimon jantimon Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh good catch - I found a missing cleanup edge case - now it's fixed and tested:

test("should track thresholds when crossing multiple in a single update", () => {
  // Using multiple specific thresholds
  const { getByTestId } = render(
    <ThresholdTriggerComponent options={{ threshold: [0.2, 0.4, 0.6, 0.8] }} />,
  );
  const element = getByTestId("threshold-trigger");

  // Initially not in view
  expect(element.getAttribute("data-trigger-count")).toBe("0");

  // Jump straight to 0.7 (crosses 0.2, 0.4, 0.6 thresholds)
  // The IntersectionObserver will still only call the callback once
  // with the highest threshold that was crossed
  mockAllIsIntersecting(0.7);
  expect(element.getAttribute("data-trigger-count")).toBe("1");
  expect(element.getAttribute("data-cleanup-count")).toBe("0");
  expect(element.getAttribute("data-last-ratio")).toBe("0.60");

  // Go out of view
  mockAllIsIntersecting(0);
  expect(element.getAttribute("data-cleanup-count")).toBe("1");

 // Change to 0.5 (crosses 0.2, 0.4 thresholds)
  mockAllIsIntersecting(0.5);
  expect(element.getAttribute("data-trigger-count")).toBe("2");
  expect(element.getAttribute("data-last-ratio")).toBe("0.40");

  // Jump to full visibility - should cleanup the 0.5 callback
  mockAllIsIntersecting(1.0);
  expect(element.getAttribute("data-trigger-count")).toBe("3");
  expect(element.getAttribute("data-cleanup-count")).toBe("2");
  expect(element.getAttribute("data-last-ratio")).toBe("0.80");
});


// Optionally return a cleanup function
return (entry) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be a usecase for the cleanup function for the consumer? You shouldn't use it to track if elements are leaving, where it's better to observe the callback entry/inView value.

Copy link
Author

@jantimon jantimon Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The advantage of a cleanup is that you have access to the closure of the viewport entering

Some example cases:

  • On View: Start tracking the size with a ResizeObserver (and stop it once it's out of view)
  • Start a poll Timer to refresh Data (and stop it once out of view)

It's also the pattern for useEffect useLayoutEffect and now with React 19 also for refs when using useCallback

console.log('Element moved out of view or unmounted');
};
},
options // Optional IntersectionObserver options
);
```

The `useOnInViewChanged` hook provides a more direct alternative to `useInView`. It takes a callback function and returns a ref that you can assign to the DOM element you want to monitor. When the element enters the viewport, your callback will be triggered.

Key differences from `useInView`:
- **No re-renders** - This hook doesn't update any state, making it ideal for performance-critical scenarios
- **Direct element access** - Your callback receives the actual DOM element and the IntersectionObserverEntry
- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport or is unmounted
- **Same options** - Accepts all the same [options](#options) as `useInView` except `onChange`

```jsx
import React from "react";
import { useOnInViewChanged } from "react-intersection-observer";

const Component = () => {
// Track when element appears without causing re-renders
const trackingRef = useOnInViewChanged((element, entry) => {
// Element is in view - perhaps log an impression
console.log("Element appeared in view");

// Return optional cleanup function
return () => {
console.log("Element left view");
};
}, {
/* Optional options */
threshold: 0.5,
});

return (
<div ref={trackingRef}>
<h2>This element is being tracked without re-renders</h2>
</div>
);
};

export default Component;
```

## Testing

> [!TIP]
Expand Down
3 changes: 3 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
}
}
},
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,17 @@
}
],
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^19.0.0",
"react-dom": "^19.0.0"
Comment on lines +111 to +112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @thebuilder @jantimon, thanks for being careful about supporting this without breaking React 18, it's critical for several projects we're working on.

Some projects can't upgrade to React 19 anytime soon due to legacy dependencies that may never support it. Since React 19 is still recent, many packages lack support, so upgrading isn't an option yet.

If React 19 becomes the only target, a major version bump would likely be needed to avoid breaking existing setups.

Appreciate the careful consideration!

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the input. And I agree - We shouldn't just break React 18. I supported React 15 and 16 until last year.

},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.2",
"@biomejs/biome": "^1.9.4",
"@size-limit/preset-small-lib": "^11.1.6",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/browser": "^2.1.8",
"@vitest/coverage-istanbul": "^2.1.8",
Expand Down
32 changes: 24 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 6 additions & 19 deletions src/InView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,29 +103,17 @@ export class InView extends React.Component<

observeNode() {
if (!this.node || this.props.skip) return;
const {
const { threshold, root, rootMargin, trackVisibility, delay } = this.props;

this._unobserveCb = observe(this.node, this.handleChange, {
threshold,
root,
rootMargin,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
fallbackInView,
} = this.props;

this._unobserveCb = observe(
this.node,
this.handleChange,
{
threshold,
root,
rootMargin,
// @ts-ignore
trackVisibility,
// @ts-ignore
delay,
},
fallbackInView,
);
});
}

unobserve() {
Expand Down Expand Up @@ -184,7 +172,6 @@ export class InView extends React.Component<
trackVisibility,
delay,
initialInView,
fallbackInView,
...props
} = this.props as PlainChildrenProps;

Expand Down
60 changes: 0 additions & 60 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
import { userEvent } from "@vitest/browser/context";
import React from "react";
import { InView } from "../InView";
import { defaultFallbackInView } from "../observe";
import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils";

test("Should render <InView /> intersecting", () => {
Expand Down Expand Up @@ -157,62 +156,3 @@ test("plain children should not catch bubbling onChange event", async () => {
await userEvent.type(input, "changed value");
expect(onChange).not.toHaveBeenCalled();
});

test("should render with fallback", () => {
const cb = vi.fn();
// @ts-ignore
window.IntersectionObserver = undefined;
render(
<InView fallbackInView={true} onChange={cb}>
Inner
</InView>,
);
expect(cb).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

render(
<InView fallbackInView={false} onChange={cb}>
Inner
</InView>,
);
expect(cb).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);

expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
render(<InView onChange={cb}>Inner</InView>);
// @ts-ignore
console.error.mockRestore();
}).toThrow();
});

test("should render with global fallback", () => {
const cb = vi.fn();
// @ts-ignore
window.IntersectionObserver = undefined;
defaultFallbackInView(true);
render(<InView onChange={cb}>Inner</InView>);
expect(cb).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

defaultFallbackInView(false);
render(<InView onChange={cb}>Inner</InView>);
expect(cb).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);

defaultFallbackInView(undefined);
expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
render(<InView onChange={cb}>Inner</InView>);
// @ts-ignore
console.error.mockRestore();
}).toThrow();
});
56 changes: 4 additions & 52 deletions src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import React, { useCallback } from "react";
import { type IntersectionOptions, defaultFallbackInView } from "../index";
import type { IntersectionOptions } from "../index";
import {
destroyIntersectionMocking,
intersectionMockInstance,
Expand Down Expand Up @@ -235,9 +235,7 @@ test("should handle ref removed", () => {
const MergeRefsComponent = ({ options }: { options?: IntersectionOptions }) => {
const [inViewRef, inView] = useInView(options);
const setRef = useCallback(
(node: Element | null) => {
inViewRef(node);
},
(node: Element | null) => inViewRef(node),
[inViewRef],
);

Expand All @@ -263,9 +261,8 @@ const MultipleHookComponent = ({

const mergedRefs = useCallback(
(node: Element | null) => {
ref1(node);
ref2(node);
ref3(node);
const cleanup = [ref1(node), ref2(node), ref3(node)];
return () => cleanup.forEach((fn) => fn());
},
[ref1, ref2, ref3],
);
Expand Down Expand Up @@ -342,51 +339,6 @@ test("should set intersection ratio as the largest threshold smaller than trigge
screen.getByText(/intersectionRatio: 0.5/);
});

test("should handle fallback if unsupported", () => {
destroyIntersectionMocking();
// @ts-ignore
window.IntersectionObserver = undefined;
const { rerender } = render(
<HookComponent options={{ fallbackInView: true }} />,
);
screen.getByText("true");

rerender(<HookComponent options={{ fallbackInView: false }} />);
screen.getByText("false");

expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
rerender(<HookComponent options={{ fallbackInView: undefined }} />);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`[TypeError: IntersectionObserver is not a constructor]`,
);
});

test("should handle defaultFallbackInView if unsupported", () => {
destroyIntersectionMocking();
// @ts-ignore
window.IntersectionObserver = undefined;
defaultFallbackInView(true);
const { rerender } = render(<HookComponent key="true" />);
screen.getByText("true");

defaultFallbackInView(false);
rerender(<HookComponent key="false" />);
screen.getByText("false");

defaultFallbackInView(undefined);
expect(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
rerender(<HookComponent key="undefined" />);
// @ts-ignore
console.error.mockRestore();
}).toThrowErrorMatchingInlineSnapshot(
`[TypeError: IntersectionObserver is not a constructor]`,
);
});

test("should restore the browser IntersectionObserver", () => {
expect(vi.isMockFunction(window.IntersectionObserver)).toBe(true);
destroyIntersectionMocking();
Expand Down
Loading