From 401964ba10086dc6d296061a203fad4d5df86a2d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 27 Oct 2025 22:20:30 +0100 Subject: [PATCH 1/9] feat: implement the useOnInView hook --- README.md | 75 ++- src/__tests__/useOnInView.test.tsx | 615 ++++++++++++++++++++++++ src/index.tsx | 28 ++ src/useOnInView.tsx | 124 +++++ storybook/stories/useOnInView.story.tsx | 173 +++++++ 5 files changed, 1013 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/useOnInView.test.tsx create mode 100644 src/useOnInView.tsx create mode 100644 storybook/stories/useOnInView.story.tsx diff --git a/README.md b/README.md index 1e954c7d..1c0d8ba9 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ to tell you when an element enters or leaves the viewport. Contains [Hooks](#use ## Features -- 🪝 **Hooks or Component API** - With `useInView` it's easier than ever to - monitor elements +- 🪝 **Hooks or Component API** - With `useInView` and `useOnInView` it's easier + than ever to monitor elements - ⚡️ **Optimized performance** - Reuses Intersection Observer instances where possible - ⚙️ **Matches native API** - Intuitive to use @@ -71,6 +71,70 @@ const Component = () => { }; ``` +### `useOnInView` hook + +```js +const inViewRef = useOnInView( + (enterEntry) => { + // Do something with the element that came into view + console.log('Element is in view', enterEntry?.target); + + // Optionally return a cleanup function + return (exitEntry) => { + console.log('Element moved out of view or unmounted'); + }; + }, + options // Optional IntersectionObserver options +); +``` + +The `useOnInView` 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 + IntersectionObserverEntry with the `target` element +- **Optional cleanup** - Return a function from your callback to run when the + element leaves the viewport +- **Similar options** - Accepts all the same [options](#options) as `useInView` + except `onChange`, `initialInView`, and `fallbackInView` + +The `trigger` option allows to listen for the element entering the viewport or +leaving the viewport. The default is `enter`. + +```jsx +import React from "react"; +import { useOnInView } from "react-intersection-observer"; + +const Component = () => { + // Track when element appears without causing re-renders + const trackingRef = useOnInView((entry) => { + // Element is in view - perhaps log an impression + console.log("Element appeared in view", entry.target); + + // Return optional cleanup function + return () => { + console.log("Element left view"); + }; + }, { + /* Optional options */ + threshold: 0.5, + trigger: "enter", + triggerOnce: true, + }); + + return ( +
+

This element is being tracked without re-renders

+
+ ); +}; +``` + ### Render props To use the `` component, you pass it a function. It will be called @@ -145,6 +209,13 @@ Provide these as the options argument in the `useInView` hook or as props on the | **initialInView** | `boolean` | `false` | Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. | | **fallbackInView** | `boolean` | `undefined` | If the `IntersectionObserver` API isn't available in the client, the default behavior is to throw an Error. You can set a specific fallback behavior, and the `inView` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` | +`useOnInView` accepts the same options as `useInView` except `onChange`, +`initialInView`, and `fallbackInView`, and adds the following configuration: + +| Name | Type | Default | Description | +| ----------- | --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| **trigger** | `"enter"` or `"leave"` | `"enter"` | Decide whether the callback runs when the element enters (`"enter"`) or leaves (`"leave"`) the viewport. | + ### InView Props The **``** component also accepts the following props: diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx new file mode 100644 index 00000000..db96d860 --- /dev/null +++ b/src/__tests__/useOnInView.test.tsx @@ -0,0 +1,615 @@ +import { render } from "@testing-library/react"; +import { useCallback, useEffect, useState } from "react"; +import type { IntersectionEffectOptions } from ".."; +import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; +import { useOnInView } from "../useOnInView"; + +const OnInViewChangedComponent = ({ + options, + unmount, +}: { + options?: IntersectionEffectOptions; + unmount?: boolean; +}) => { + const [inView, setInView] = useState(false); + const [callCount, setCallCount] = useState(0); + const [cleanupCount, setCleanupCount] = useState(0); + + const inViewRef = useOnInView((entry) => { + setInView(entry.isIntersecting); + setCallCount((prev) => prev + 1); + // Return cleanup function + return (cleanupEntry) => { + setCleanupCount((prev) => prev + 1); + if (cleanupEntry) { + setInView(false); + } + }; + }, options); + + return ( +
+ {inView.toString()} +
+ ); +}; + +const LazyOnInViewChangedComponent = ({ + options, +}: { + options?: IntersectionEffectOptions; +}) => { + const [isLoading, setIsLoading] = useState(true); + const [inView, setInView] = useState(false); + + useEffect(() => { + setIsLoading(false); + }, []); + + const inViewRef = useOnInView((entry) => { + setInView(entry ? entry.isIntersecting : false); + return () => setInView(false); + }, options); + + if (isLoading) return
Loading
; + + return ( +
+ {inView.toString()} +
+ ); +}; + +const OnInViewChangedComponentWithoutClenaup = ({ + options, + unmount, +}: { + options?: IntersectionEffectOptions; + unmount?: boolean; +}) => { + const [callCount, setCallCount] = useState(0); + const inViewRef = useOnInView(() => { + setCallCount((prev) => prev + 1); + }, options); + + return ( +
+ ); +}; + +const ThresholdTriggerComponent = ({ + options, +}: { + options?: IntersectionEffectOptions; +}) => { + const [triggerCount, setTriggerCount] = useState(0); + const [cleanupCount, setCleanupCount] = useState(0); + const [lastRatio, setLastRatio] = useState(null); + const [triggeredThresholds, setTriggeredThresholds] = useState([]); + + const inViewRef = useOnInView((entry) => { + setTriggerCount((prev) => prev + 1); + setLastRatio(entry.intersectionRatio); + + // Add this ratio to our list of triggered thresholds + setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); + + return (exitEntry) => { + setCleanupCount((prev) => prev + 1); + if (exitEntry) { + setLastRatio(exitEntry.intersectionRatio); + } + }; + }, options); + + return ( +
+ Tracking thresholds +
+ ); +}; + +test("should create a hook with useOnInView", () => { + const { getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); +}); + +test("should create a hook with array threshold", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); +}); + +test("should create a lazy hook with useOnInView", () => { + const { getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); +}); + +test("should call the callback when element comes into view", () => { + const { getByTestId } = render(); + mockAllIsIntersecting(true); + + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); +}); + +test("should call cleanup when element leaves view", () => { + const { getByTestId } = render(); + mockAllIsIntersecting(true); + mockAllIsIntersecting(false); + + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); +}); + +test("should respect threshold values", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + mockAllIsIntersecting(0.2); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + + mockAllIsIntersecting(0.5); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + + mockAllIsIntersecting(1); + expect(wrapper.getAttribute("data-inview")).toBe("true"); +}); + +test("should call callback with trigger: leave", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + mockAllIsIntersecting(false); + // Should call callback + expect(wrapper.getAttribute("data-call-count")).toBe("1"); + + mockAllIsIntersecting(true); + // Should call cleanup + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); +}); + +test("should call callback with trigger: leave and triggerOnce is true", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + mockAllIsIntersecting(true); + // the callback should not be called as it is triggered on leave + expect(wrapper.getAttribute("data-call-count")).toBe("0"); + + mockAllIsIntersecting(false); + // Should call callback + expect(wrapper.getAttribute("data-call-count")).toBe("1"); + + mockAllIsIntersecting(true); + // Should call cleanup + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); +}); + +test("should respect triggerOnce option", () => { + const { getByTestId } = render( + <> + + + , + ); + const wrapper = getByTestId("wrapper"); + const wrapperTriggerOnce = getByTestId("wrapper-no-cleanup"); + + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); + expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); + mockAllIsIntersecting(false); + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-call-count")).toBe("2"); + expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); +}); + +test("should respect skip option", () => { + const { getByTestId, rerender } = render( + , + ); + mockAllIsIntersecting(true); + + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + expect(wrapper.getAttribute("data-call-count")).toBe("0"); + + rerender(); + mockAllIsIntersecting(true); + + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); +}); + +test("should handle unmounting properly", () => { + const { unmount, getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + unmount(); + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); +}); + +test("should handle ref changes", () => { + const { rerender, getByTestId } = render(); + mockAllIsIntersecting(true); + + rerender(); + + // Component should clean up when ref is removed + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); + + // Add the ref back + rerender(); + mockAllIsIntersecting(true); + + expect(wrapper.getAttribute("data-inview")).toBe("true"); +}); + +// Test for merging refs +const MergeRefsComponent = ({ + options, +}: { + options?: IntersectionEffectOptions; +}) => { + const [inView, setInView] = useState(false); + + const inViewRef = useOnInView((entry) => { + setInView(entry ? entry.isIntersecting : false); + return () => setInView(false); + }, options); + + const setRef = useCallback( + (node: Element | null) => inViewRef(node), + [inViewRef], + ); + + return ( +
+ ); +}; + +test("should handle merged refs", () => { + const { rerender, getByTestId } = render(); + mockAllIsIntersecting(true); + rerender(); + + expect(getByTestId("inview").getAttribute("data-inview")).toBe("true"); +}); + +// Test multiple callbacks on the same element +const MultipleCallbacksComponent = ({ + options, +}: { + options?: IntersectionEffectOptions; +}) => { + const [inView1, setInView1] = useState(false); + const [inView2, setInView2] = useState(false); + const [inView3, setInView3] = useState(false); + + const ref1 = useOnInView((entry) => { + setInView1(entry ? entry.isIntersecting : false); + return () => setInView1(false); + }, options); + + const ref2 = useOnInView((entry) => { + setInView2(entry ? entry.isIntersecting : false); + return () => setInView2(false); + }, options); + + const ref3 = useOnInView((entry) => { + setInView3(entry ? entry.isIntersecting : false); + return () => setInView3(false); + }); + + const mergedRefs = useCallback( + (node: Element | null) => { + const cleanup = [ref1(node), ref2(node), ref3(node)]; + return () => + cleanup.forEach((fn) => { + fn?.(); + }); + }, + [ref1, ref2, ref3], + ); + + return ( +
+
+ {inView1.toString()} +
+
+ {inView2.toString()} +
+
+ {inView3.toString()} +
+
+ ); +}; + +test("should handle multiple callbacks on the same element", () => { + const { getByTestId } = render( + , + ); + mockAllIsIntersecting(true); + + expect(getByTestId("item-1").getAttribute("data-inview")).toBe("true"); + expect(getByTestId("item-2").getAttribute("data-inview")).toBe("true"); + expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true"); +}); + +test("should pass the element to the callback", () => { + let capturedElement: Element | undefined; + + const ElementTestComponent = () => { + const inViewRef = useOnInView((entry) => { + capturedElement = entry.target; + return undefined; + }); + + return
; + }; + + const { getByTestId } = render(); + const element = getByTestId("element-test"); + mockAllIsIntersecting(true); + + expect(capturedElement).toBe(element); +}); + +test("should track which threshold triggered the visibility change", () => { + // Using multiple specific thresholds + const { getByTestId } = render( + , + ); + const element = getByTestId("threshold-trigger"); + + // Initially not in view + expect(element.getAttribute("data-trigger-count")).toBe("0"); + + // Trigger at exactly the first threshold (0.25) + mockAllIsIntersecting(0.25); + expect(element.getAttribute("data-trigger-count")).toBe("1"); + expect(element.getAttribute("data-last-ratio")).toBe("0.25"); + + // Go out of view + mockAllIsIntersecting(0); + + // Trigger at exactly the second threshold (0.5) + mockAllIsIntersecting(0.5); + expect(element.getAttribute("data-trigger-count")).toBe("2"); + expect(element.getAttribute("data-last-ratio")).toBe("0.50"); + + // Go out of view + mockAllIsIntersecting(0); + + // Trigger at exactly the third threshold (0.75) + mockAllIsIntersecting(0.75); + expect(element.getAttribute("data-trigger-count")).toBe("3"); + expect(element.getAttribute("data-last-ratio")).toBe("0.75"); + + // Check all triggered thresholds were recorded + const triggeredThresholds = JSON.parse( + element.getAttribute("data-triggered-thresholds") || "[]", + ); + expect(triggeredThresholds).toContain(0.25); + expect(triggeredThresholds).toContain(0.5); + expect(triggeredThresholds).toContain(0.75); +}); + +test("should track thresholds when crossing multiple in a single update", () => { + // Using multiple specific thresholds + const { getByTestId } = render( + , + ); + 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"); +}); + +test("should track thresholds when trigger is set to leave", () => { + // Using multiple specific thresholds with trigger: leave + const { getByTestId } = render( + , + ); + const element = getByTestId("threshold-trigger"); + + // Make element 30% visible - above first threshold, should call cleanup + mockAllIsIntersecting(0); + expect(element.getAttribute("data-trigger-count")).toBe("1"); + expect(element.getAttribute("data-last-ratio")).toBe("0.00"); +}); + +test("should allow destroying the observer after custom condition is met", () => { + // Component that stops observing after a specific number of views + const DestroyAfterCountComponent = ({ maxViewCount = 2 }) => { + const [viewCount, setViewCount] = useState(0); + const [inView, setInView] = useState(false); + const [observerDestroyed, setObserverDestroyed] = useState(false); + + const inViewRef = useOnInView((entry, destroyObserver) => { + setInView(entry.isIntersecting); + + // Increment view count when element comes into view + if (entry.isIntersecting) { + const newCount = viewCount + 1; + setViewCount(newCount); + + // If we've reached the max view count, destroy the observer + if (newCount >= maxViewCount) { + destroyObserver(); + setObserverDestroyed(true); + } + } + + return () => { + setInView(false); + }; + }); + + return ( +
+ Destroy after {maxViewCount} views +
+ ); + }; + + const { getByTestId } = render(); + const wrapper = getByTestId("destroy-test"); + const instance = intersectionMockInstance(wrapper); + + // Initially not in view + expect(wrapper.getAttribute("data-view-count")).toBe("0"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); + + // First view + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-view-count")).toBe("1"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); + + // Back out of view + mockAllIsIntersecting(false); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + + // Second view - should hit max count and destroy observer + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-view-count")).toBe("2"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); + + // Verify unobserve was called when destroying the observer + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); + + // Additional intersection changes should have no effect since observer is destroyed + mockAllIsIntersecting(false); + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-view-count")).toBe("2"); // Count should not increase +}); + +test("should allow destroying the observer immediately on first visibility", () => { + // This is useful for one-time animations or effects that should only run once + const DestroyImmediatelyComponent = () => { + const [hasBeenVisible, setHasBeenVisible] = useState(false); + const [observerDestroyed, setObserverDestroyed] = useState(false); + + const inViewRef = useOnInView((entry, destroyObserver) => { + if (entry.isIntersecting) { + setHasBeenVisible(true); + destroyObserver(); + setObserverDestroyed(true); + } + + return undefined; + }); + + return ( +
+ Destroy immediately +
+ ); + }; + + const { getByTestId } = render(); + const wrapper = getByTestId("destroy-immediate"); + + // Initially not visible + expect(wrapper.getAttribute("data-has-been-visible")).toBe("false"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); + + // Trigger visibility + mockAllIsIntersecting(true); + + // Should have been marked as visible and destroyed + expect(wrapper.getAttribute("data-has-been-visible")).toBe("true"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); + + // Additional intersection changes should have no effect + mockAllIsIntersecting(false); + mockAllIsIntersecting(true); + + // State should remain the same + expect(wrapper.getAttribute("data-has-been-visible")).toBe("true"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); +}); diff --git a/src/index.tsx b/src/index.tsx index 8e42c133..1cdfe45a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import type * as React from "react"; export { InView } from "./InView"; export { defaultFallbackInView, observe } from "./observe"; export { useInView } from "./useInView"; +export { useOnInView } from "./useOnInView"; type Omit = Pick>; @@ -13,6 +14,15 @@ export type ObserverInstanceCallback = ( entry: IntersectionObserverEntry, ) => void; +export type IntersectionChangeCleanup = ( + entry?: IntersectionObserverEntry, +) => void; + +export type IntersectionChangeEffect = ( + entry: IntersectionObserverEntry, + unobserve: () => void, +) => void | IntersectionChangeCleanup; + interface RenderProps { inView: boolean; entry: IntersectionObserverEntry | undefined; @@ -83,3 +93,21 @@ export type InViewHookResponse = [ inView: boolean; entry?: IntersectionObserverEntry; }; + +export interface IntersectionEffectOptions + extends Pick< + IntersectionOptions, + | "threshold" + | "root" + | "rootMargin" + | "trackVisibility" + | "delay" + | "triggerOnce" + | "skip" + > { + /** + * Trigger the callback when the element either enters (`enter`) or leaves (`leave`) the viewport. + * @default "enter" + */ + trigger?: "enter" | "leave"; +} diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx new file mode 100644 index 00000000..0f5447be --- /dev/null +++ b/src/useOnInView.tsx @@ -0,0 +1,124 @@ +import * as React from "react"; +import type { + IntersectionChangeEffect, + IntersectionEffectOptions, +} from "./index"; +import { observe } from "./observe"; + +/** + * React Hooks make it easy to monitor when elements come into and leave view. Call + * the `useOnInView` hook with your callback and (optional) [options](#options). + * It will return a ref callback that you can assign to the DOM element you want to monitor. + * When the element enters or leaves the viewport, your callback will be triggered. + * + * This hook triggers no re-renders, and is useful for performance-critical use-cases or + * when you need to trigger render independent side effects like tracking or logging. + * + * @example + * ```jsx + * import React from 'react'; + * import { useOnInView } from 'react-intersection-observer'; + * + * const Component = () => { + * const inViewRef = useOnInView((entry) => { + * console.log(`Element is in view`, entry?.target); + * // Optional: cleanup function: + * return () => { + * console.log('Element moved out of view or unmounted'); + * }; + * }, { + * threshold: 0, + * }); + * + * return ( + *
+ *

This element is being monitored

+ *
+ * ); + * }; + * ``` + */ +export const useOnInView = ( + onIntersectionChange: IntersectionChangeEffect, + { + threshold, + root, + rootMargin, + trackVisibility, + delay, + triggerOnce, + skip, + trigger, + }: IntersectionEffectOptions = {}, +) => { + const onIntersectionChangeRef = React.useRef(onIntersectionChange); + const syncEffect = + React.useInsertionEffect ?? React.useLayoutEffect ?? React.useEffect; + + syncEffect(() => { + onIntersectionChangeRef.current = onIntersectionChange; + }, [onIntersectionChange]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Threshold is validated to be stable + return React.useCallback( + (element: TElement | undefined | null) => { + if (!element || skip) { + return; + } + + let callbackCleanup: + | undefined + | ReturnType>; + + const intersectionsStateTrigger = trigger !== "leave"; + + const destroyInViewObserver = observe( + element, + (inView, entry) => { + if (callbackCleanup) { + callbackCleanup(entry); + callbackCleanup = undefined; + if (triggerOnce) { + destroyInViewObserver(); + return; + } + } + + if (inView === intersectionsStateTrigger) { + callbackCleanup = onIntersectionChangeRef.current( + entry, + destroyInViewObserver, + ); + + if (triggerOnce && !callbackCleanup) { + destroyInViewObserver(); + } + } + }, + { + threshold, + root, + rootMargin, + // @ts-expect-error Track visibility is a non-standard extension + trackVisibility, + delay, + }, + ); + + return () => { + destroyInViewObserver(); + callbackCleanup?.(); + }; + }, + [ + Array.isArray(threshold) ? threshold.toString() : threshold, + root, + rootMargin, + trackVisibility, + delay, + triggerOnce, + skip, + trigger, + ], + ); +}; diff --git a/storybook/stories/useOnInView.story.tsx b/storybook/stories/useOnInView.story.tsx new file mode 100644 index 00000000..ab8bb5a0 --- /dev/null +++ b/storybook/stories/useOnInView.story.tsx @@ -0,0 +1,173 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useMemo, useState } from "react"; +import { + type IntersectionEffectOptions, + type IntersectionOptions, + useOnInView, +} from "react-intersection-observer"; +import { + EntryDetails, + ErrorMessage, + InViewBlock, + InViewIcon, + RootMargin, + ScrollWrapper, + Status, + ThresholdMarker, +} from "./elements"; +import { argTypes, useValidateOptions } from "./story-utils"; + +type Props = IntersectionEffectOptions & { + trigger?: NonNullable; +}; + +type Story = StoryObj; + +const meta = { + title: "useOnInView Hook", + parameters: { + controls: { + expanded: true, + }, + }, + argTypes: { + ...argTypes, + trigger: { + control: { type: "inline-radio" }, + options: ["enter", "leave"], + description: + 'Trigger the callback when the element enters ("enter") or leaves ("leave") the viewport.', + }, + }, + args: { + threshold: 0, + trigger: "enter", + triggerOnce: false, + skip: false, + }, + render: UseOnInViewRender, +} satisfies Meta; + +export default meta; + +function UseOnInViewRender({ trigger = "enter", ...rest }: Props) { + const { options, error } = useValidateOptions(rest as IntersectionOptions); + + const { onChange, initialInView, fallbackInView, ...observerOptions } = + options; + + const effectOptions: IntersectionEffectOptions | undefined = error + ? undefined + : { + ...observerOptions, + trigger, + }; + + const [inView, setInView] = useState(false); + const [events, setEvents] = useState([]); + + const optionsKey = useMemo( + () => + JSON.stringify({ + trigger, + threshold: effectOptions?.threshold, + rootMargin: effectOptions?.rootMargin, + trackVisibility: effectOptions?.trackVisibility, + delay: effectOptions?.delay, + triggerOnce: effectOptions?.triggerOnce, + skip: effectOptions?.skip, + }), + [ + effectOptions?.delay, + effectOptions?.rootMargin, + effectOptions?.skip, + effectOptions?.threshold, + effectOptions?.trackVisibility, + effectOptions?.triggerOnce, + trigger, + ], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset when options change + useEffect(() => { + setEvents([]); + setInView(false); + }, [optionsKey]); + + const ref = useOnInView((entry) => { + setInView(true); + setEvents((prev) => [ + ...prev, + `Entered viewport at ${(entry.time / 1000).toFixed(2)}s`, + ]); + return (exitEntry) => { + setInView(false); + setEvents((prev) => [ + ...prev, + exitEntry + ? `Exited viewport at ${(exitEntry.time / 1000).toFixed(2)}s` + : "Observer disconnected or element unmounted", + ]); + }; + }, effectOptions); + + if (error) { + return {error}; + } + + return ( + + + + + +
+

Event log

+
+ {events.length === 0 ? ( +

+ Scroll this element in and out of view to trigger the callback. +

+ ) : ( +
    + {events.map((event, index) => ( +
  • {event}
  • + ))} +
+ )} +
+
+ {effectOptions?.skip ? ( +

+ Observing is currently skipped. Toggle `skip` off to monitor the + element. +

+ ) : null} +
+ + +
+ ); +} + +export const Basic: Story = { + args: {}, +}; + +export const LeaveTrigger: Story = { + args: { + trigger: "leave", + }, +}; + +export const TriggerOnce: Story = { + args: { + triggerOnce: true, + }, +}; + +export const SkipObserver: Story = { + args: { + skip: true, + }, +}; From ab1859dc4010fbbda3411ce78ba9b131199a29f3 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 27 Oct 2025 22:24:10 +0100 Subject: [PATCH 2/9] handle older versions of React --- src/useOnInView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 0f5447be..20f0089a 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -53,7 +53,13 @@ export const useOnInView = ( ) => { const onIntersectionChangeRef = React.useRef(onIntersectionChange); const syncEffect = - React.useInsertionEffect ?? React.useLayoutEffect ?? React.useEffect; + ( + React as typeof React & { + useInsertionEffect?: typeof React.useEffect; + } + ).useInsertionEffect ?? + React.useLayoutEffect ?? + React.useEffect; syncEffect(() => { onIntersectionChangeRef.current = onIntersectionChange; From 805d8be0b21b2f60708393bb3356ddc2e531a4a7 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 27 Oct 2025 23:12:26 +0100 Subject: [PATCH 3/9] handle older versions of React --- src/useOnInView.tsx | 95 ++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 20f0089a..fdb02906 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -5,6 +5,15 @@ import type { } from "./index"; import { observe } from "./observe"; +const useSyncEffect = + ( + React as typeof React & { + useInsertionEffect?: typeof React.useEffect; + } + ).useInsertionEffect ?? + React.useLayoutEffect ?? + React.useEffect; + /** * React Hooks make it easy to monitor when elements come into and leave view. Call * the `useOnInView` hook with your callback and (optional) [options](#options). @@ -52,52 +61,71 @@ export const useOnInView = ( }: IntersectionEffectOptions = {}, ) => { const onIntersectionChangeRef = React.useRef(onIntersectionChange); - const syncEffect = - ( - React as typeof React & { - useInsertionEffect?: typeof React.useEffect; - } - ).useInsertionEffect ?? - React.useLayoutEffect ?? - React.useEffect; + const observedElementRef = React.useRef(null); + const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined); + const callbackCleanupRef = + React.useRef>>(undefined); - syncEffect(() => { + useSyncEffect(() => { onIntersectionChangeRef.current = onIntersectionChange; }, [onIntersectionChange]); - // biome-ignore lint/correctness/useExhaustiveDependencies: Threshold is validated to be stable + // biome-ignore lint/correctness/useExhaustiveDependencies: Threshold arrays are normalized inside the callback return React.useCallback( (element: TElement | undefined | null) => { + // React <19 never calls ref callbacks with `null` during unmount, so we + // eagerly tear down existing observers manually whenever the target changes. + const cleanupExisting = () => { + if (observerCleanupRef.current) { + const cleanup = observerCleanupRef.current; + observerCleanupRef.current = undefined; + cleanup(); + } else if (callbackCleanupRef.current) { + const cleanup = callbackCleanupRef.current; + callbackCleanupRef.current = undefined; + cleanup(); + } + }; + + if (element === observedElementRef.current) { + return observerCleanupRef.current; + } + if (!element || skip) { + observedElementRef.current = element ?? null; + cleanupExisting(); return; } - let callbackCleanup: - | undefined - | ReturnType>; + cleanupExisting(); + observedElementRef.current = element; const intersectionsStateTrigger = trigger !== "leave"; + let destroyed = false; - const destroyInViewObserver = observe( + const destroyObserver = observe( element, (inView, entry) => { - if (callbackCleanup) { - callbackCleanup(entry); - callbackCleanup = undefined; + if (callbackCleanupRef.current) { + const cleanup = callbackCleanupRef.current; + callbackCleanupRef.current = undefined; + cleanup(entry); if (triggerOnce) { - destroyInViewObserver(); + stopObserving(); return; } } if (inView === intersectionsStateTrigger) { - callbackCleanup = onIntersectionChangeRef.current( + const nextCleanup = onIntersectionChangeRef.current( entry, - destroyInViewObserver, + stopObserving, ); + callbackCleanupRef.current = + typeof nextCleanup === "function" ? nextCleanup : undefined; - if (triggerOnce && !callbackCleanup) { - destroyInViewObserver(); + if (triggerOnce && !callbackCleanupRef.current) { + stopObserving(); } } }, @@ -105,16 +133,27 @@ export const useOnInView = ( threshold, root, rootMargin, - // @ts-expect-error Track visibility is a non-standard extension trackVisibility, delay, - }, + } as IntersectionObserverInit, ); - return () => { - destroyInViewObserver(); - callbackCleanup?.(); - }; + function stopObserving() { + // Centralized teardown so both manual destroys and React ref updates share + // the same cleanup path (needed for React versions that never call the ref with `null`). + if (destroyed) return; + destroyed = true; + destroyObserver(); + observedElementRef.current = null; + const cleanup = callbackCleanupRef.current; + callbackCleanupRef.current = undefined; + cleanup?.(); + observerCleanupRef.current = undefined; + } + + observerCleanupRef.current = stopObserving; + + return observerCleanupRef.current; }, [ Array.isArray(threshold) ? threshold.toString() : threshold, From 10db13d3911a1ad2b415784c37de5b497ab4f8de Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 27 Oct 2025 23:34:25 +0100 Subject: [PATCH 4/9] fix: correct typo --- src/__tests__/useOnInView.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index db96d860..98f5e70e 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -66,7 +66,7 @@ const LazyOnInViewChangedComponent = ({ ); }; -const OnInViewChangedComponentWithoutClenaup = ({ +const OnInViewChangedComponentWithoutCleanup = ({ options, unmount, }: { @@ -227,7 +227,7 @@ test("should respect triggerOnce option", () => { const { getByTestId } = render( <> - + , ); const wrapper = getByTestId("wrapper"); From 12a90650a1b89fd414948726dccbee6cd8e30cc7 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 28 Oct 2025 12:41:09 +0100 Subject: [PATCH 5/9] use the same onChange signature --- README.md | 47 ++--- src/__tests__/useOnInView.test.tsx | 251 +++--------------------- src/index.tsx | 31 +-- src/useOnInView.tsx | 53 ++--- storybook/stories/useOnInView.story.tsx | 53 ++--- 5 files changed, 87 insertions(+), 348 deletions(-) diff --git a/README.md b/README.md index 1c0d8ba9..a09fa16a 100644 --- a/README.md +++ b/README.md @@ -90,42 +90,41 @@ const inViewRef = useOnInView( The `useOnInView` 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. +element you want to monitor. Whenever the element enters or leaves the viewport, +your callback will be triggered with the latest in-view state. 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 IntersectionObserverEntry with the `target` element -- **Optional cleanup** - Return a function from your callback to run when the - element leaves the viewport +- **Boolean-first callback** - The callback receives the current `inView` + boolean as the first argument, matching the `onChange` signature from + `useInView` - **Similar options** - Accepts all the same [options](#options) as `useInView` except `onChange`, `initialInView`, and `fallbackInView` -The `trigger` option allows to listen for the element entering the viewport or -leaving the viewport. The default is `enter`. - ```jsx import React from "react"; import { useOnInView } from "react-intersection-observer"; const Component = () => { // Track when element appears without causing re-renders - const trackingRef = useOnInView((entry) => { - // Element is in view - perhaps log an impression - console.log("Element appeared in view", entry.target); - - // Return optional cleanup function - return () => { - console.log("Element left view"); - }; - }, { - /* Optional options */ - threshold: 0.5, - trigger: "enter", - triggerOnce: true, - }); + const trackingRef = useOnInView( + (inView, entry) => { + if (inView) { + // Element is in view - perhaps log an impression + console.log("Element appeared in view", entry.target); + } else { + console.log("Element left view", entry.target); + } + }, + { + /* Optional options */ + threshold: 0.5, + triggerOnce: true, + }, + ); return (
@@ -210,11 +209,7 @@ Provide these as the options argument in the `useInView` hook or as props on the | **fallbackInView** | `boolean` | `undefined` | If the `IntersectionObserver` API isn't available in the client, the default behavior is to throw an Error. You can set a specific fallback behavior, and the `inView` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` | `useOnInView` accepts the same options as `useInView` except `onChange`, -`initialInView`, and `fallbackInView`, and adds the following configuration: - -| Name | Type | Default | Description | -| ----------- | --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| **trigger** | `"enter"` or `"leave"` | `"enter"` | Decide whether the callback runs when the element enters (`"enter"`) or leaves (`"leave"`) the viewport. | +`initialInView`, and `fallbackInView`. ### InView Props diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index 98f5e70e..404034ff 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -15,16 +15,12 @@ const OnInViewChangedComponent = ({ const [callCount, setCallCount] = useState(0); const [cleanupCount, setCleanupCount] = useState(0); - const inViewRef = useOnInView((entry) => { - setInView(entry.isIntersecting); + const inViewRef = useOnInView((isInView) => { + setInView(isInView); setCallCount((prev) => prev + 1); - // Return cleanup function - return (cleanupEntry) => { + if (!isInView) { setCleanupCount((prev) => prev + 1); - if (cleanupEntry) { - setInView(false); - } - }; + } }, options); return ( @@ -52,9 +48,8 @@ const LazyOnInViewChangedComponent = ({ setIsLoading(false); }, []); - const inViewRef = useOnInView((entry) => { - setInView(entry ? entry.isIntersecting : false); - return () => setInView(false); + const inViewRef = useOnInView((isInView) => { + setInView(isInView); }, options); if (isLoading) return
Loading
; @@ -97,19 +92,16 @@ const ThresholdTriggerComponent = ({ const [lastRatio, setLastRatio] = useState(null); const [triggeredThresholds, setTriggeredThresholds] = useState([]); - const inViewRef = useOnInView((entry) => { + const inViewRef = useOnInView((isInView, entry) => { setTriggerCount((prev) => prev + 1); setLastRatio(entry.intersectionRatio); - // Add this ratio to our list of triggered thresholds - setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); - - return (exitEntry) => { + if (isInView) { + // Add this ratio to our list of triggered thresholds + setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); + } else { setCleanupCount((prev) => prev + 1); - if (exitEntry) { - setLastRatio(exitEntry.intersectionRatio); - } - }; + } }, options); return ( @@ -187,42 +179,6 @@ test("should respect threshold values", () => { expect(wrapper.getAttribute("data-inview")).toBe("true"); }); -test("should call callback with trigger: leave", () => { - const { getByTestId } = render( - , - ); - const wrapper = getByTestId("wrapper"); - - mockAllIsIntersecting(false); - // Should call callback - expect(wrapper.getAttribute("data-call-count")).toBe("1"); - - mockAllIsIntersecting(true); - // Should call cleanup - expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); -}); - -test("should call callback with trigger: leave and triggerOnce is true", () => { - const { getByTestId } = render( - , - ); - const wrapper = getByTestId("wrapper"); - - mockAllIsIntersecting(true); - // the callback should not be called as it is triggered on leave - expect(wrapper.getAttribute("data-call-count")).toBe("0"); - - mockAllIsIntersecting(false); - // Should call callback - expect(wrapper.getAttribute("data-call-count")).toBe("1"); - - mockAllIsIntersecting(true); - // Should call cleanup - expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); -}); - test("should respect triggerOnce option", () => { const { getByTestId } = render( <> @@ -239,7 +195,7 @@ test("should respect triggerOnce option", () => { mockAllIsIntersecting(false); expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); mockAllIsIntersecting(true); - expect(wrapper.getAttribute("data-call-count")).toBe("2"); + expect(wrapper.getAttribute("data-call-count")).toBe("3"); expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); }); @@ -272,10 +228,11 @@ test("should handle unmounting properly", () => { test("should handle ref changes", () => { const { rerender, getByTestId } = render(); mockAllIsIntersecting(true); + mockAllIsIntersecting(false); rerender(); - // Component should clean up when ref is removed + // Component should register the element leaving view before ref removal const wrapper = getByTestId("wrapper"); expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); @@ -294,9 +251,8 @@ const MergeRefsComponent = ({ }) => { const [inView, setInView] = useState(false); - const inViewRef = useOnInView((entry) => { - setInView(entry ? entry.isIntersecting : false); - return () => setInView(false); + const inViewRef = useOnInView((isInView) => { + setInView(isInView); }, options); const setRef = useCallback( @@ -327,19 +283,16 @@ const MultipleCallbacksComponent = ({ const [inView2, setInView2] = useState(false); const [inView3, setInView3] = useState(false); - const ref1 = useOnInView((entry) => { - setInView1(entry ? entry.isIntersecting : false); - return () => setInView1(false); + const ref1 = useOnInView((isInView) => { + setInView1(isInView); }, options); - const ref2 = useOnInView((entry) => { - setInView2(entry ? entry.isIntersecting : false); - return () => setInView2(false); + const ref2 = useOnInView((isInView) => { + setInView2(isInView); }, options); - const ref3 = useOnInView((entry) => { - setInView3(entry ? entry.isIntersecting : false); - return () => setInView3(false); + const ref3 = useOnInView((isInView) => { + setInView3(isInView); }); const mergedRefs = useCallback( @@ -383,9 +336,8 @@ test("should pass the element to the callback", () => { let capturedElement: Element | undefined; const ElementTestComponent = () => { - const inViewRef = useOnInView((entry) => { + const inViewRef = useOnInView((_, entry) => { capturedElement = entry.target; - return undefined; }); return
; @@ -415,18 +367,20 @@ test("should track which threshold triggered the visibility change", () => { // Go out of view mockAllIsIntersecting(0); + expect(element.getAttribute("data-trigger-count")).toBe("2"); // Trigger at exactly the second threshold (0.5) mockAllIsIntersecting(0.5); - expect(element.getAttribute("data-trigger-count")).toBe("2"); + expect(element.getAttribute("data-trigger-count")).toBe("3"); expect(element.getAttribute("data-last-ratio")).toBe("0.50"); // Go out of view mockAllIsIntersecting(0); + expect(element.getAttribute("data-trigger-count")).toBe("4"); // Trigger at exactly the third threshold (0.75) mockAllIsIntersecting(0.75); - expect(element.getAttribute("data-trigger-count")).toBe("3"); + expect(element.getAttribute("data-trigger-count")).toBe("5"); expect(element.getAttribute("data-last-ratio")).toBe("0.75"); // Check all triggered thresholds were recorded @@ -459,157 +413,16 @@ test("should track thresholds when crossing multiple in a single update", () => // Go out of view mockAllIsIntersecting(0); expect(element.getAttribute("data-cleanup-count")).toBe("1"); + expect(element.getAttribute("data-trigger-count")).toBe("2"); // 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-trigger-count")).toBe("3"); 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-trigger-count")).toBe("4"); + expect(element.getAttribute("data-cleanup-count")).toBe("1"); expect(element.getAttribute("data-last-ratio")).toBe("0.80"); }); - -test("should track thresholds when trigger is set to leave", () => { - // Using multiple specific thresholds with trigger: leave - const { getByTestId } = render( - , - ); - const element = getByTestId("threshold-trigger"); - - // Make element 30% visible - above first threshold, should call cleanup - mockAllIsIntersecting(0); - expect(element.getAttribute("data-trigger-count")).toBe("1"); - expect(element.getAttribute("data-last-ratio")).toBe("0.00"); -}); - -test("should allow destroying the observer after custom condition is met", () => { - // Component that stops observing after a specific number of views - const DestroyAfterCountComponent = ({ maxViewCount = 2 }) => { - const [viewCount, setViewCount] = useState(0); - const [inView, setInView] = useState(false); - const [observerDestroyed, setObserverDestroyed] = useState(false); - - const inViewRef = useOnInView((entry, destroyObserver) => { - setInView(entry.isIntersecting); - - // Increment view count when element comes into view - if (entry.isIntersecting) { - const newCount = viewCount + 1; - setViewCount(newCount); - - // If we've reached the max view count, destroy the observer - if (newCount >= maxViewCount) { - destroyObserver(); - setObserverDestroyed(true); - } - } - - return () => { - setInView(false); - }; - }); - - return ( -
- Destroy after {maxViewCount} views -
- ); - }; - - const { getByTestId } = render(); - const wrapper = getByTestId("destroy-test"); - const instance = intersectionMockInstance(wrapper); - - // Initially not in view - expect(wrapper.getAttribute("data-view-count")).toBe("0"); - expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); - - // First view - mockAllIsIntersecting(true); - expect(wrapper.getAttribute("data-inview")).toBe("true"); - expect(wrapper.getAttribute("data-view-count")).toBe("1"); - expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); - - // Back out of view - mockAllIsIntersecting(false); - expect(wrapper.getAttribute("data-inview")).toBe("false"); - - // Second view - should hit max count and destroy observer - mockAllIsIntersecting(true); - expect(wrapper.getAttribute("data-inview")).toBe("true"); - expect(wrapper.getAttribute("data-view-count")).toBe("2"); - expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); - - // Verify unobserve was called when destroying the observer - expect(instance.unobserve).toHaveBeenCalledWith(wrapper); - - // Additional intersection changes should have no effect since observer is destroyed - mockAllIsIntersecting(false); - mockAllIsIntersecting(true); - expect(wrapper.getAttribute("data-view-count")).toBe("2"); // Count should not increase -}); - -test("should allow destroying the observer immediately on first visibility", () => { - // This is useful for one-time animations or effects that should only run once - const DestroyImmediatelyComponent = () => { - const [hasBeenVisible, setHasBeenVisible] = useState(false); - const [observerDestroyed, setObserverDestroyed] = useState(false); - - const inViewRef = useOnInView((entry, destroyObserver) => { - if (entry.isIntersecting) { - setHasBeenVisible(true); - destroyObserver(); - setObserverDestroyed(true); - } - - return undefined; - }); - - return ( -
- Destroy immediately -
- ); - }; - - const { getByTestId } = render(); - const wrapper = getByTestId("destroy-immediate"); - - // Initially not visible - expect(wrapper.getAttribute("data-has-been-visible")).toBe("false"); - expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); - - // Trigger visibility - mockAllIsIntersecting(true); - - // Should have been marked as visible and destroyed - expect(wrapper.getAttribute("data-has-been-visible")).toBe("true"); - expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); - - // Additional intersection changes should have no effect - mockAllIsIntersecting(false); - mockAllIsIntersecting(true); - - // State should remain the same - expect(wrapper.getAttribute("data-has-been-visible")).toBe("true"); - expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); -}); diff --git a/src/index.tsx b/src/index.tsx index 1cdfe45a..4afd36bb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,14 +14,10 @@ export type ObserverInstanceCallback = ( entry: IntersectionObserverEntry, ) => void; -export type IntersectionChangeCleanup = ( - entry?: IntersectionObserverEntry, -) => void; - export type IntersectionChangeEffect = ( - entry: IntersectionObserverEntry, - unobserve: () => void, -) => void | IntersectionChangeCleanup; + inView: boolean, + entry: IntersectionObserverEntry & { target: TElement }, +) => void; interface RenderProps { inView: boolean; @@ -94,20 +90,7 @@ export type InViewHookResponse = [ entry?: IntersectionObserverEntry; }; -export interface IntersectionEffectOptions - extends Pick< - IntersectionOptions, - | "threshold" - | "root" - | "rootMargin" - | "trackVisibility" - | "delay" - | "triggerOnce" - | "skip" - > { - /** - * Trigger the callback when the element either enters (`enter`) or leaves (`leave`) the viewport. - * @default "enter" - */ - trigger?: "enter" | "leave"; -} +export type IntersectionEffectOptions = Omit< + IntersectionOptions, + "onChange" | "fallbackInView" | "initialInView" +>; diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index fdb02906..63ae3678 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -29,14 +29,12 @@ const useSyncEffect = * import { useOnInView } from 'react-intersection-observer'; * * const Component = () => { - * const inViewRef = useOnInView((entry) => { - * console.log(`Element is in view`, entry?.target); - * // Optional: cleanup function: - * return () => { - * console.log('Element moved out of view or unmounted'); - * }; - * }, { - * threshold: 0, + * const inViewRef = useOnInView((inView, entry) => { + * if (inView) { + * console.log("Element is in view", entry.target); + * } else { + * console.log("Element left view", entry.target); + * } * }); * * return ( @@ -57,14 +55,11 @@ export const useOnInView = ( delay, triggerOnce, skip, - trigger, }: IntersectionEffectOptions = {}, ) => { const onIntersectionChangeRef = React.useRef(onIntersectionChange); const observedElementRef = React.useRef(null); const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined); - const callbackCleanupRef = - React.useRef>>(undefined); useSyncEffect(() => { onIntersectionChangeRef.current = onIntersectionChange; @@ -80,10 +75,6 @@ export const useOnInView = ( const cleanup = observerCleanupRef.current; observerCleanupRef.current = undefined; cleanup(); - } else if (callbackCleanupRef.current) { - const cleanup = callbackCleanupRef.current; - callbackCleanupRef.current = undefined; - cleanup(); } }; @@ -100,33 +91,17 @@ export const useOnInView = ( cleanupExisting(); observedElementRef.current = element; - const intersectionsStateTrigger = trigger !== "leave"; let destroyed = false; const destroyObserver = observe( element, (inView, entry) => { - if (callbackCleanupRef.current) { - const cleanup = callbackCleanupRef.current; - callbackCleanupRef.current = undefined; - cleanup(entry); - if (triggerOnce) { - stopObserving(); - return; - } - } - - if (inView === intersectionsStateTrigger) { - const nextCleanup = onIntersectionChangeRef.current( - entry, - stopObserving, - ); - callbackCleanupRef.current = - typeof nextCleanup === "function" ? nextCleanup : undefined; - - if (triggerOnce && !callbackCleanupRef.current) { - stopObserving(); - } + onIntersectionChangeRef.current( + inView, + entry as IntersectionObserverEntry & { target: TElement }, + ); + if (triggerOnce && inView) { + stopObserving(); } }, { @@ -145,9 +120,6 @@ export const useOnInView = ( destroyed = true; destroyObserver(); observedElementRef.current = null; - const cleanup = callbackCleanupRef.current; - callbackCleanupRef.current = undefined; - cleanup?.(); observerCleanupRef.current = undefined; } @@ -163,7 +135,6 @@ export const useOnInView = ( delay, triggerOnce, skip, - trigger, ], ); }; diff --git a/storybook/stories/useOnInView.story.tsx b/storybook/stories/useOnInView.story.tsx index ab8bb5a0..e1d58766 100644 --- a/storybook/stories/useOnInView.story.tsx +++ b/storybook/stories/useOnInView.story.tsx @@ -17,9 +17,7 @@ import { } from "./elements"; import { argTypes, useValidateOptions } from "./story-utils"; -type Props = IntersectionEffectOptions & { - trigger?: NonNullable; -}; +type Props = IntersectionEffectOptions; type Story = StoryObj; @@ -32,16 +30,9 @@ const meta = { }, argTypes: { ...argTypes, - trigger: { - control: { type: "inline-radio" }, - options: ["enter", "leave"], - description: - 'Trigger the callback when the element enters ("enter") or leaves ("leave") the viewport.', - }, }, args: { threshold: 0, - trigger: "enter", triggerOnce: false, skip: false, }, @@ -50,7 +41,7 @@ const meta = { export default meta; -function UseOnInViewRender({ trigger = "enter", ...rest }: Props) { +function UseOnInViewRender(rest: Props) { const { options, error } = useValidateOptions(rest as IntersectionOptions); const { onChange, initialInView, fallbackInView, ...observerOptions } = @@ -58,10 +49,7 @@ function UseOnInViewRender({ trigger = "enter", ...rest }: Props) { const effectOptions: IntersectionEffectOptions | undefined = error ? undefined - : { - ...observerOptions, - trigger, - }; + : observerOptions; const [inView, setInView] = useState(false); const [events, setEvents] = useState([]); @@ -69,7 +57,6 @@ function UseOnInViewRender({ trigger = "enter", ...rest }: Props) { const optionsKey = useMemo( () => JSON.stringify({ - trigger, threshold: effectOptions?.threshold, rootMargin: effectOptions?.rootMargin, trackVisibility: effectOptions?.trackVisibility, @@ -84,7 +71,6 @@ function UseOnInViewRender({ trigger = "enter", ...rest }: Props) { effectOptions?.threshold, effectOptions?.trackVisibility, effectOptions?.triggerOnce, - trigger, ], ); @@ -94,21 +80,18 @@ function UseOnInViewRender({ trigger = "enter", ...rest }: Props) { setInView(false); }, [optionsKey]); - const ref = useOnInView((entry) => { - setInView(true); - setEvents((prev) => [ - ...prev, - `Entered viewport at ${(entry.time / 1000).toFixed(2)}s`, - ]); - return (exitEntry) => { - setInView(false); - setEvents((prev) => [ - ...prev, - exitEntry - ? `Exited viewport at ${(exitEntry.time / 1000).toFixed(2)}s` - : "Observer disconnected or element unmounted", - ]); - }; + const ref = useOnInView((isInView, entry) => { + setInView(isInView); + const seconds = + Number.isFinite(entry.time) && entry.time >= 0 + ? (entry.time / 1000).toFixed(2) + : undefined; + const label = seconds + ? `${isInView ? "Entered" : "Left"} viewport at ${seconds}s` + : isInView + ? "Entered viewport" + : "Left viewport"; + setEvents((prev) => [...prev, label]); }, effectOptions); if (error) { @@ -154,12 +137,6 @@ export const Basic: Story = { args: {}, }; -export const LeaveTrigger: Story = { - args: { - trigger: "leave", - }, -}; - export const TriggerOnce: Story = { args: { triggerOnce: true, From 75f9f2859e752910abe438f330e847c2162709bc Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 28 Oct 2025 13:57:11 +0100 Subject: [PATCH 6/9] docs: update readme --- README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a09fa16a..283f50cf 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,13 @@ const Component = () => { ```js const inViewRef = useOnInView( - (enterEntry) => { - // Do something with the element that came into view - console.log('Element is in view', enterEntry?.target); - - // Optionally return a cleanup function - return (exitEntry) => { - console.log('Element moved out of view or unmounted'); - }; + (inView, entry) => { + if (inView) { + // Do something with the element that came into view + console.log("Element is in view", entry.target); + } else { + console.log("Element left view", entry.target); + } }, options // Optional IntersectionObserver options ); From d6bf2e7a4f1af53650048da399c77f8ecda4e287 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 28 Oct 2025 14:38:28 +0100 Subject: [PATCH 7/9] feat: update useOnInView implementation and adjust package limits --- docs/Recipes.md | 19 +++++++++---------- package.json | 10 ++++++++-- src/useOnInView.tsx | 2 +- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/Recipes.md b/docs/Recipes.md index 100fa763..9f1bffd5 100644 --- a/docs/Recipes.md +++ b/docs/Recipes.md @@ -106,7 +106,8 @@ export default LazyAnimation; ## Track impressions You can use `IntersectionObserver` to track when a user views your element, and -fire an event on your tracking service. +fire an event on your tracking service. Consider using the `useOnInView` to +trigger changes via a callback. - Set `triggerOnce`, to only trigger an event the first time the element enters the viewport. @@ -115,22 +116,20 @@ fire an event on your tracking service. - Instead of `threshold`, you can use `rootMargin` to have a fixed amount be visible before triggering. Use a negative margin value, like `-100px 0px`, to have it go inwards. You can also use a percentage value, instead of pixels. -- You can use the `onChange` callback to trigger the tracking. ```jsx import * as React from "react"; -import { useInView } from "react-intersection-observer"; +import { useOnInView } from "react-intersection-observer"; const TrackImpression = () => { - const { ref } = useInView({ - triggerOnce: true, - rootMargin: "-100px 0", - onChange: (inView) => { + const ref = useOnInView((inView) => { if (inView) { - // Fire a tracking event to your tracking service of choice. - dataLayer.push("Section shown"); // Here's a GTM dataLayer push + // Fire a tracking event to your tracking service of choice. + dataLayer.push("Section shown"); // Here's a GTM dataLayer push } - }, + }, { + triggerOnce: true, + rootMargin: "-100px 0", }); return ( diff --git a/package.json b/package.json index 32dfdfcb..1e4c0cee 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "path": "dist/index.mjs", "name": "InView", "import": "{ InView }", - "limit": "1.8 kB" + "limit": "1.5 kB" }, { "path": "dist/index.mjs", @@ -102,11 +102,17 @@ "import": "{ useInView }", "limit": "1.3 kB" }, + { + "path": "dist/index.mjs", + "name": "useOnInView", + "import": "{ useOnInView }", + "limit": "1.1 kB" + }, { "path": "dist/index.mjs", "name": "observe", "import": "{ observe }", - "limit": "1 kB" + "limit": "0.9 kB" } ], "peerDependencies": { diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 63ae3678..740ae896 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -83,8 +83,8 @@ export const useOnInView = ( } if (!element || skip) { - observedElementRef.current = element ?? null; cleanupExisting(); + observedElementRef.current = null; return; } From b18146388d66201761a7bfbda21b7028ce56570d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 28 Oct 2025 15:24:08 +0100 Subject: [PATCH 8/9] feat: adjust onChange so it only trigger after changes --- README.md | 16 +++++++++++----- src/InView.tsx | 14 ++++++++++++++ src/__tests__/InView.test.tsx | 11 +++++++---- .../{hooks.test.tsx => useInView.test.tsx} | 5 ++++- src/__tests__/useOnInView.test.tsx | 11 +++++++++++ src/useInView.tsx | 13 +++++++++++++ src/useOnInView.tsx | 11 +++++++++++ .../{Hooks.story.tsx => useInView.story.tsx} | 0 8 files changed, 71 insertions(+), 10 deletions(-) rename src/__tests__/{hooks.test.tsx => useInView.test.tsx} (98%) rename storybook/stories/{Hooks.story.tsx => useInView.story.tsx} (100%) diff --git a/README.md b/README.md index 283f50cf..e7705635 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ const Component = () => { }; ``` +> **Note:** The first `false` notification from the underlying IntersectionObserver is ignored so your handlers only run after a real visibility change. Subsequent transitions still report both `true` and `false` states as the element enters and leaves the viewport. + ### `useOnInView` hook ```js @@ -103,6 +105,8 @@ Key differences from `useInView`: - **Similar options** - Accepts all the same [options](#options) as `useInView` except `onChange`, `initialInView`, and `fallbackInView` +> **Note:** Just like `useInView`, the initial `false` notification is skipped. Your callback fires the first time the element becomes visible (and on every subsequent enter/leave transition). + ```jsx import React from "react"; import { useOnInView } from "react-intersection-observer"; @@ -149,9 +153,9 @@ state. ```jsx import { InView } from "react-intersection-observer"; -const Component = () => ( - - {({ inView, ref, entry }) => ( + const Component = () => ( + + {({ inView, ref, entry }) => (

{`Header inside viewport ${inView}.`}

@@ -159,8 +163,10 @@ const Component = () => (
); -export default Component; -``` + export default Component; + ``` + +> **Note:** `` mirrors the hook behaviour—it suppresses the very first `false` notification so render props and `onChange` handlers only run after a genuine visibility change. ### Plain children diff --git a/src/InView.tsx b/src/InView.tsx index a552f3fb..300723f6 100644 --- a/src/InView.tsx +++ b/src/InView.tsx @@ -68,6 +68,7 @@ export class InView extends React.Component< > { node: Element | null = null; _unobserveCb: (() => void) | null = null; + lastInView: boolean | undefined; constructor(props: IntersectionObserverProps | PlainChildrenProps) { super(props); @@ -75,6 +76,7 @@ export class InView extends React.Component< inView: !!props.initialInView, entry: undefined, }; + this.lastInView = props.initialInView; } componentDidMount() { @@ -112,6 +114,9 @@ export class InView extends React.Component< fallbackInView, } = this.props; + if (this.lastInView === undefined) { + this.lastInView = this.props.initialInView; + } this._unobserveCb = observe( this.node, this.handleChange, @@ -142,6 +147,7 @@ export class InView extends React.Component< if (!node && !this.props.triggerOnce && !this.props.skip) { // Reset the state if we get a new node, and we aren't ignoring updates this.setState({ inView: !!this.props.initialInView, entry: undefined }); + this.lastInView = this.props.initialInView; } } @@ -150,6 +156,14 @@ export class InView extends React.Component< }; handleChange = (inView: boolean, entry: IntersectionObserverEntry) => { + const previousInView = this.lastInView; + this.lastInView = inView; + + // Ignore the very first `false` notification so consumers only hear about actual state changes. + if (previousInView === undefined && !inView) { + return; + } + if (inView && this.props.triggerOnce) { // If `triggerOnce` is true, we should stop observing the element. this.unobserve(); diff --git a/src/__tests__/InView.test.tsx b/src/__tests__/InView.test.tsx index 7892332e..204401e0 100644 --- a/src/__tests__/InView.test.tsx +++ b/src/__tests__/InView.test.tsx @@ -13,16 +13,19 @@ test("Should render intersecting", () => { ); mockAllIsIntersecting(false); - expect(callback).toHaveBeenLastCalledWith( - false, - expect.objectContaining({ isIntersecting: false }), - ); + expect(callback).not.toHaveBeenCalled(); mockAllIsIntersecting(true); expect(callback).toHaveBeenLastCalledWith( true, expect.objectContaining({ isIntersecting: true }), ); + + mockAllIsIntersecting(false); + expect(callback).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ isIntersecting: false }), + ); }); test("should render plain children", () => { diff --git a/src/__tests__/hooks.test.tsx b/src/__tests__/useInView.test.tsx similarity index 98% rename from src/__tests__/hooks.test.tsx rename to src/__tests__/useInView.test.tsx index 1b265422..e5c9bd18 100644 --- a/src/__tests__/hooks.test.tsx +++ b/src/__tests__/useInView.test.tsx @@ -112,6 +112,9 @@ test("should trigger onChange", () => { const onChange = vi.fn(); render(); + mockAllIsIntersecting(false); + expect(onChange).not.toHaveBeenCalled(); + mockAllIsIntersecting(true); expect(onChange).toHaveBeenLastCalledWith( true, @@ -191,7 +194,7 @@ const SwitchHookComponent = ({ />
diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index 404034ff..ed9ccab0 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -153,6 +153,17 @@ test("should call the callback when element comes into view", () => { expect(wrapper.getAttribute("data-call-count")).toBe("1"); }); +test("should ignore initial false intersection", () => { + const { getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + + mockAllIsIntersecting(false); + expect(wrapper.getAttribute("data-call-count")).toBe("0"); + + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); +}); + test("should call cleanup when element leaves view", () => { const { getByTestId } = render(); mockAllIsIntersecting(true); diff --git a/src/useInView.tsx b/src/useInView.tsx index 2cf4b1b8..3a0dc60f 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -47,6 +47,7 @@ export function useInView({ }: IntersectionOptions = {}): InViewHookResponse { const [ref, setRef] = React.useState(null); const callback = React.useRef(onChange); + const lastInViewRef = React.useRef(initialInView); const [state, setState] = React.useState({ inView: !!initialInView, entry: undefined, @@ -59,6 +60,9 @@ export function useInView({ // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency React.useEffect( () => { + if (lastInViewRef.current === undefined) { + lastInViewRef.current = initialInView; + } // Ensure we have node ref, and that we shouldn't skip observing if (skip || !ref) return; @@ -66,6 +70,14 @@ export function useInView({ unobserve = observe( ref, (inView, entry) => { + const previousInView = lastInViewRef.current; + lastInViewRef.current = inView; + + // Ignore the very first `false` notification so consumers only hear about actual state changes. + if (previousInView === undefined && !inView) { + return; + } + setState({ inView, entry, @@ -127,6 +139,7 @@ export function useInView({ inView: !!initialInView, entry: undefined, }); + lastInViewRef.current = initialInView; } const result = [setRef, state.inView, state.entry] as InViewHookResponse; diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 740ae896..25440f3d 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -60,6 +60,7 @@ export const useOnInView = ( const onIntersectionChangeRef = React.useRef(onIntersectionChange); const observedElementRef = React.useRef(null); const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined); + const lastInViewRef = React.useRef(undefined); useSyncEffect(() => { onIntersectionChangeRef.current = onIntersectionChange; @@ -85,6 +86,7 @@ export const useOnInView = ( if (!element || skip) { cleanupExisting(); observedElementRef.current = null; + lastInViewRef.current = undefined; return; } @@ -96,6 +98,14 @@ export const useOnInView = ( const destroyObserver = observe( element, (inView, entry) => { + const previousInView = lastInViewRef.current; + lastInViewRef.current = inView; + + // Ignore the very first `false` notification so consumers only hear about actual state changes. + if (previousInView === undefined && !inView) { + return; + } + onIntersectionChangeRef.current( inView, entry as IntersectionObserverEntry & { target: TElement }, @@ -121,6 +131,7 @@ export const useOnInView = ( destroyObserver(); observedElementRef.current = null; observerCleanupRef.current = undefined; + lastInViewRef.current = undefined; } observerCleanupRef.current = stopObserving; diff --git a/storybook/stories/Hooks.story.tsx b/storybook/stories/useInView.story.tsx similarity index 100% rename from storybook/stories/Hooks.story.tsx rename to storybook/stories/useInView.story.tsx From 8cd8e428351ffc26b0420ee28b68c133d3b2c5f1 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 28 Oct 2025 15:29:02 +0100 Subject: [PATCH 9/9] test: fix data attribute --- src/__tests__/useInView.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx index e5c9bd18..f97c0957 100644 --- a/src/__tests__/useInView.test.tsx +++ b/src/__tests__/useInView.test.tsx @@ -189,12 +189,12 @@ const SwitchHookComponent = ({ <>