Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Open
jantimon wants to merge17 commits intothebuilder:main
base:main
Choose a base branch
Loading
fromjantimon:react19-useCallback

Conversation

@jantimon
Copy link

@jantimonjantimon commentedFeb 28, 2025
edited
Loading

Hello! I have created a PR that uses React 19's newref 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 changeconstref=useCallback((node)=>{if(node){// Setup codereturn()=>{// Cleanup code when ref is detached};}},[deps]);

What's Changed

  • RewroteuseInView to use ref callback cleanup instead ofuseEffect
  • Added a newuseOnInViewChanged hook which doesn't trigger re-renders (great for analytics/impression tracking)
  • Removed old fallback handling for browsers withoutIntersectionObserver support

Size Improvements

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

ComponentBeforeAfterDiff
InView1.24 kB1.14 kB-8%
useInView1.02 kB967 B-5%
observe711 B616 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: RequiresIntersectionObserver 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

alejo4373 reacted with thumbs up emoji
@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz CodeflowRun & review this pull request inStackBlitz Codeflow.

@thebuilder
Copy link
Owner

thebuilder commentedFeb 28, 2025
edited
Loading

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 theIntersectionObserver 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.

@thebuilder
Copy link
Owner

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

@vercel
Copy link

vercelbot commentedFeb 28, 2025
edited
Loading

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

NameStatusPreviewCommentsUpdated (UTC)
react-intersection-observer✅ Ready (Inspect)Visit Preview💬Add feedbackApr 9, 2025 2:02pm

@jantimon
Copy link
Author

jantimon commentedFeb 28, 2025
edited
Loading

Thank you for your quick reply

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

CorrectuseInView is no longer callingobserve - instead it callsuseOnInViewChanged (which callsobserve)
TheuseInView react 19 version has only the booleaninView state - therefore it does no longer rerender to store the element ref

useOnInViewChanged is stateless but requires react 19

What would thedependencies 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";exportconstuseInView:typeofuseInView18=version.startsWith("19") ?useInView19 :useInView18;

I also added some docs and tests foruseOnInViewChanged

@jantimon
Copy link
Author

Found another optimization:

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

I fixedobserver.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 commentedMar 3, 2025
edited
Loading

Cool I am looking forward to it

I guess acanary tag ornexttag would also be fine - that way an update would be an explicit opt-in and thelatest 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:

constonGetsIntoViewRef=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 theuseEvent RFC:
reactjs/rfcs#220

Which was merged as experimentaluseEffectEvent
facebook/react#25881

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

I changeduseOnInViewChanged to also useReact.useInsertionEffect so nowuseOnInViewChanged anduseInView 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 callbackentry/inView value.

Copy link
Author

@jantimonjantimonMar 4, 2025
edited
Loading

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 foruseEffectuseLayoutEffect and now with React 19 also for refs when usinguseCallback

README.md Outdated
constinViewRef=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 theelement, they should be able to get it fromentry.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 ifinitialInView istrue:

constinViewRef=useOnInViewChanged((enterEntry)=>{// Do something with the element that came into viewconsole.log('Element is in view',enterEntry?.element);// Optionally return a cleanup functionreturn(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 withuseOnViewChanged anyway - It's for avoiding flashing content on the initial render.

Copy link
Author

@jantimonjantimonMar 4, 2025
edited
Loading

Choose a reason for hiding this comment

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

maybe the nameuseOnInViewChanged misleading and should beuseOnInViewEntered oruseOnInViewEffect

useInView usesuseOnInViewChanged and therefore has to pass over theinitialInView 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 ofuseOnInViewChanged slightly might get rid of the undefined entry case and handle theinitialInVIew better

I'll let you know if it works

thebuilder reacted with thumbs up emoji
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 intouseOnInView.

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 acceptsinitialInView
  • useOnInView accepts now atrigger option (which is set toenter by default but can be changed toleave):
consttrackingRef=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 useuseOnInView insideuseInView for theinitialInView case

it also solved the non existingentry 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 theentry

Copy link
Author

@jantimonjantimonMar 5, 2025
edited
Loading

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 thresholdsconst{ getByTestId}=render(<ThresholdTriggerComponentoptions={{threshold:[0.2,0.4,0.6,0.8]}}/>,);constelement=getByTestId("threshold-trigger");// Initially not in viewexpect(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 crossedmockAllIsIntersecting(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 viewmockAllIsIntersecting(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 callbackmockAllIsIntersecting(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 commentedMar 7, 2025
edited
Loading

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 than100px 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!

thebuilder reacted with thumbs up emoji
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.

@thebuilderthebuilder requested a review fromCopilotApril 8, 2025 12:05
Copy link

CopilotAI left a comment

Choose a reason for hiding this comment

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

Copilot reviewed 9 out of 11 changed files in this pull request and generated no comments.

Files not reviewed (2)
  • biome.json: Language not supported
  • package.json: Language not supported
Comments suppressed due to low confidence (3)

src/useInView.tsx:72

  • [nitpick] The use of '!initialInView' to determine the current in-view state can be confusing. Consider renaming the variable or adding a comment to clarify that the state toggles based on the initial value and the trigger configuration.
const inView = !initialInView;

src/observe.ts:121

  • The explicit type cast here might be masking potential type issues. Consider refactoring the types or adjusting the function signature to avoid the need for casting.
callbacks.push(callback as ObserverInstanceCallback);

src/useOnInView.tsx:123

  • [nitpick] Consider rephrasing this comment to explain the rationale behind converting the threshold to a string for dependency stability, ensuring it's clear why this deviation is necessary.
// We break the rule here, because we aren't including the actual `threshold` variable

@jantimon
Copy link
Author

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 callsunobserve it will modify the callbacks array which is currently iterated over and therefore the index position skips over one callback

@dohomi
Copy link

great efforts and looking forward for this being merged 👍

thebuilder added a commit that referenced this pull requestOct 28, 2025
Based on the great work in#718 by@jantimon - This is an attempt to implement just the useOnInView hook, while maintaining the fallback functionality, and legacy React version support.### ✨ New- **`useOnInView` hook** — a no-re-render alternative to `useInView` that delivers `(inView, entry)` to your callback while returning a ref you can attach to any element. Designed for tracking, analytics, and other side effect heavy workloads where state updates are unnecessary.- **`IntersectionChangeEffect` / `IntersectionEffectOptions` types** — exported helper types that describe the new hook’s callback and options surface.- **Storybook playground + documentation** — new story, README section, and JSDoc example demonstrating how to use `useOnInView`.### ✨ Improvements- `useInView`, `useOnInView`, and `<InView>` now ignore the browser’s initial `inView === false` emission so handlers only fire once a real visibility change occurs, while still reporting all subsequent enter/leave transitions (including threshold arrays).- Observer cleanup logic across the hooks/components was tightened to ensure `skip` toggles and fallback scenarios re-attach correctly without losing previous state.### 🧪 Testing- Added a dedicated Vitest suite for `useOnInView`, covering thresholds, `triggerOnce`, `skip` toggling, merged refs, and multiple observers on the same node.
@jantimon
Copy link
Author

@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 ofuseEffectEvent

@thebuilder
Copy link
Owner

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

It would be possible to applyuseEffectEvent, which I did try - but in theuseOnInView, it would be used inside auseCallback (which would not be the correct usage for theuseEffectEvent).

Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment

Reviewers

@thebuilderthebuilderthebuilder left review comments

Copilot code reviewCopilotCopilot left review comments

+1 more reviewer

@GeorgeGkasGeorgeGkasGeorgeGkas left review comments

Reviewers whose approvals may not affect merge requirements

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

4 participants

@jantimon@thebuilder@dohomi@GeorgeGkas

[8]ページ先頭

©2009-2025 Movatter.jp