Skip to content
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

use of React 19 ref callbacks for IntersectionObserver tracking #718

Open
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

jantimon
Copy link

@jantimon jantimon commented Feb 28, 2025

Hello! I have created a PR that uses React 19's new ref callback cleanup functionality to simplify the implementation of this library for better performance

Background

React 19 introduced cleanup functions for ref callbacks. This allows us to handle both attaching and detaching observers in one place without needing separate useEffect hooks and state management

// React 19 ref callback with cleanup
// it re-executes once deps change
const ref = useCallback((node) => {
  if (node) {
    // Setup code
    return () => {
      // Cleanup code when ref is detached
    };
  }
}, [deps]);

What's Changed

  • Rewrote useInView to use ref callback cleanup instead of useEffect
  • Added a new useOnInViewChanged hook which doesn't trigger re-renders (great for analytics/impression tracking)
  • Removed old fallback handling for browsers without IntersectionObserver support

Size Improvements

The changes result in slightly smaller bundle size although it exposes an additional hook:

Component Before After Diff
InView 1.24 kB 1.14 kB -8%
useInView 1.02 kB 967 B -5%
observe 711 B 616 B -13%

Breaking Changes

This quite an update and I hope you are fine with these two rather opinionated changes:

  1. React 19 Required: Uses the new ref callback cleanup API
  2. No Fallback: Requires IntersectionObserver to be available (has been supported in all major browsers for 5+ years now)

Why I Made These Changes

The new implementation is not only smaller but also has better performance since it:

  • Reduces re-renders
  • Simplifies logic by handling setup/teardown in a single location
  • Introduces a render-free hook for impression tracking (useOnInViewChanged)

All tests are passing with these changes. I did remove the tests that were specifically for the fallback functionality

What do you think? I'm happy to adjust the implementation if you have any concerns or ideas

Copy link

stackblitz bot commented Feb 28, 2025

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@thebuilder
Copy link
Owner

thebuilder commented Feb 28, 2025

Thanks alot for putting in the work on these features.

React 19 Ref
I had considered if it was worth doing a refactor to use the new cleanup - But, I don't think we can't just drop support for React 18.
It would be interesting to look into supporting both paths, depending on the version of React that's being used. Minor size increase to the package, but would cut the rerenders down for React 19 users.

Fallback support
Browsers have supported the IntersectionObserver for a long time, so in theory the fallback should not be needed.

  • One of the reasons it was added in the first place was because some browsers (usually Safari), would just reject the observers on some devices. This might not be an issue anymore.
  • Another reason, is to facility testing in JSDom environments, by adding a default logic.

skip,
initialInView,
}: IntersectionOptions = {},
dependencies: React.DependencyList = [],
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 the dependencies be? Doesn't seem like it's needed?

@thebuilder
Copy link
Owner

Am I correct in understanding, that it only removes the extra re-renders if using the useOnInViewChanged?

Copy link

vercel bot commented Feb 28, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
react-intersection-observer ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 7, 2025 9:19am

@jantimon
Copy link
Author

jantimon commented Feb 28, 2025

Thank you for your quick reply

Am I correct in understanding, that it only removes the extra re-renders if using the useOnInViewChanged?

Correct useInView is no longer calling observe - instead it calls useOnInViewChanged (which calls observe)
The useInView react 19 version has only the boolean inView state - therefore it does no longer rerender to store the element ref

useOnInViewChanged is stateless but requires react 19

What would the dependencies be? Doesn't seem like it's needed?

Good catch - changing the dependencies will destroy the observer and create it once again.
However you are right there is no real use case because of the ref for the callback - I removed it

React 18 support

I can think of these three options:

  1. Manual Opt-in by installing a different major version using a specific npm packages tag e.g. npm i react-intersection-observer@next for react 19

  2. Manual Opt-in by explicitly importing the react19 version (might be problematic because of auto imports)
    import { useInView } from "react-intersection-observer/react19";

  3. Auto detection (doubles the hook size)

import { version } from "react"
import { useInView18 } from "./useInView18";
import { useInView19 } from "./useInView19";

export const useInView: typeof useInView18 = version.startsWith("19") ? useInView19 : useInView18;

I also added some docs and tests for useOnInViewChanged

@jantimon
Copy link
Author

jantimon commented Mar 3, 2025

Found another optimization:

Currently the same element would be added multiple time to the same observer

I fixed observer.ts to add it only once

@thebuilder
Copy link
Owner

Thanks a lot for doing all this. I think the conditional version switch, is the safest bet right now. It's a small hook, so overhead is minimal.
I just know that dropping react 18, will create a ton of issues, with people that can't upgrade to React 19 yet for reasons.

I'm a bit tied up at work, so haven't had time to properly dig into and test the changes.

@jantimon
Copy link
Author

jantimon commented Mar 3, 2025

Cool I am looking forward to it

I guess a canary tag or next tag would also be fine - that way an update would be an explicit opt-in and the latest tag would still be backwards compatible

You probably know that React fiber allows to pause and resume renderings

Unfortunately this comes with a cost - the following is no longer allowed:

const onGetsIntoViewRef = React.useRef(onGetsIntoView);
onGetsIntoViewRef.current = onGetsIntoView;

https://react.dev/reference/react/useRef#caveats

Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable.

There was the useEvent RFC:
reactjs/rfcs#220

Which was merged as experimental useEffectEvent
facebook/react#25881

There is a polyfill which uses React.useInsertionEffect (because it runs before useEffect and useLayoutEffect):
https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts

I changed useOnInViewChanged to also use React.useInsertionEffect so now useOnInViewChanged and useInView should be fine

README.md Outdated
console.log('Element is in view', element);

// 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

README.md Outdated
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");
});

@jantimon
Copy link
Author

jantimon commented Mar 7, 2025

This might be an extrem scenario but it illustrates how flexible the new hook is and how convenient the cleanup feature can be.

Track only the first impression if the element is larger than 100px without rerenderings:

carbon (5)

Comment on lines +111 to +112
"react": "^19.0.0",
"react-dom": "^19.0.0"

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants