react-intersection-observer icon indicating copy to clipboard operation
react-intersection-observer copied to clipboard

use of React 19 ref callbacks for IntersectionObserver tracking

Open jantimon opened this issue 9 months ago • 13 comments

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

jantimon avatar Feb 28 '25 16:02 jantimon

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

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.
    • See: https://github.com/thebuilder/react-intersection-observer/issues/495
  • Another reason, is to facility testing in JSDom environments, by adding a default logic.

thebuilder avatar Feb 28 '25 20:02 thebuilder

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

thebuilder avatar Feb 28 '25 20:02 thebuilder

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 Apr 9, 2025 2:02pm

vercel[bot] avatar Feb 28 '25 22:02 vercel[bot]

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 avatar Feb 28 '25 22:02 jantimon

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

jantimon avatar Mar 03 '25 13:03 jantimon

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.

thebuilder avatar Mar 03 '25 19:03 thebuilder

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: https://github.com/reactjs/rfcs/pull/220

Which was merged as experimental useEffectEvent https://github.com/facebook/react/pull/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

jantimon avatar Mar 03 '25 21:03 jantimon

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)

jantimon avatar Mar 07 '25 09:03 jantimon

just fixed another bug on this branch

the following code executes all callbacks for the given dom element:

elements.get(entry.target)?.forEach((callback) => {
   callback(inView, entry);
});

however if callback calls unobserve it will modify the callbacks array which is currently iterated over and therefore the index position skips over one callback

jantimon avatar Apr 09 '25 14:04 jantimon

great efforts and looking forward for this being merged 👍

dohomi avatar Jul 31 '25 01:07 dohomi

@thebuilder really cool that you merged over the hook!

Thank you so much - and I really like the idea of just using a ref with the useEffect

I saw that your solution did not take in all performance optimisations

the code size is 30% larger than the #718 solution.. do you think it would make sense to try moving over the optimizations to main? or do you think it would be possible to prepare a new major for react 19.2 +?

that way we could make use of useEffectEvent

jantimon avatar Oct 29 '25 10:10 jantimon

It is larger since it still has the fallback and React 17+ support.

It would be possible to apply useEffectEvent, which I did try - but in the useOnInView, it would be used inside a useCallback (which would not be the correct usage for the useEffectEvent).

thebuilder avatar Oct 29 '25 12:10 thebuilder