react-intersection-observer
react-intersection-observer copied to clipboard
use of React 19 ref callbacks for IntersectionObserver tracking
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
useInViewto use ref callback cleanup instead ofuseEffect - Added a new
useOnInViewChangedhook which doesn't trigger re-renders (great for analytics/impression tracking) - Removed old fallback handling for browsers without
IntersectionObserversupport
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:
- React 19 Required: Uses the new ref callback cleanup API
- No Fallback: Requires
IntersectionObserverto 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
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.
Am I correct in understanding, that it only removes the extra re-renders if using the useOnInViewChanged?
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 |
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
dependenciesbe? 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:
-
Manual Opt-in by installing a different major version using a specific npm packages tag e.g.
npm i react-intersection-observer@nextfor react 19 -
Manual Opt-in by explicitly importing the react19 version (might be problematic because of auto imports)
import { useInView } from "react-intersection-observer/react19"; -
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
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
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.
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
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:
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
great efforts and looking forward for this being merged 👍
@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
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).