diff --git a/README.md b/README.md index 1e954c7d..e7705635 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,72 @@ 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 +const inViewRef = useOnInView( + (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 +); +``` + +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. 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 +- **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` + +> **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"; + +const Component = () => { + // Track when element appears without causing re-renders + 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 ( +
+

This element is being tracked without re-renders

+
+ ); +}; +``` + ### Render props To use the `` component, you pass it a function. It will be called @@ -87,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}.`}

@@ -97,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 @@ -145,6 +213,9 @@ 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`. + ### InView Props The **``** component also accepts the following props: 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/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..f97c0957 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, @@ -186,12 +189,12 @@ const SwitchHookComponent = ({ <>
diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx new file mode 100644 index 00000000..ed9ccab0 --- /dev/null +++ b/src/__tests__/useOnInView.test.tsx @@ -0,0 +1,439 @@ +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((isInView) => { + setInView(isInView); + setCallCount((prev) => prev + 1); + if (!isInView) { + setCleanupCount((prev) => prev + 1); + } + }, 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((isInView) => { + setInView(isInView); + }, options); + + if (isLoading) return
Loading
; + + return ( +
+ {inView.toString()} +
+ ); +}; + +const OnInViewChangedComponentWithoutCleanup = ({ + 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((isInView, entry) => { + setTriggerCount((prev) => prev + 1); + setLastRatio(entry.intersectionRatio); + + if (isInView) { + // Add this ratio to our list of triggered thresholds + setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); + } else { + setCleanupCount((prev) => prev + 1); + } + }, 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 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); + 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 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("3"); + 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); + mockAllIsIntersecting(false); + + rerender(); + + // Component should register the element leaving view before ref removal + 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((isInView) => { + setInView(isInView); + }, 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((isInView) => { + setInView1(isInView); + }, options); + + const ref2 = useOnInView((isInView) => { + setInView2(isInView); + }, options); + + const ref3 = useOnInView((isInView) => { + setInView3(isInView); + }); + + 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
; + }; + + 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); + 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("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("5"); + 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"); + 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("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("4"); + expect(element.getAttribute("data-cleanup-count")).toBe("1"); + expect(element.getAttribute("data-last-ratio")).toBe("0.80"); +}); diff --git a/src/index.tsx b/src/index.tsx index 8e42c133..4afd36bb 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,11 @@ export type ObserverInstanceCallback = ( entry: IntersectionObserverEntry, ) => void; +export type IntersectionChangeEffect = ( + inView: boolean, + entry: IntersectionObserverEntry & { target: TElement }, +) => void; + interface RenderProps { inView: boolean; entry: IntersectionObserverEntry | undefined; @@ -83,3 +89,8 @@ export type InViewHookResponse = [ inView: boolean; entry?: IntersectionObserverEntry; }; + +export type IntersectionEffectOptions = Omit< + IntersectionOptions, + "onChange" | "fallbackInView" | "initialInView" +>; 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 new file mode 100644 index 00000000..25440f3d --- /dev/null +++ b/src/useOnInView.tsx @@ -0,0 +1,151 @@ +import * as React from "react"; +import type { + IntersectionChangeEffect, + IntersectionEffectOptions, +} 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). + * 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((inView, entry) => { + * if (inView) { + * console.log("Element is in view", entry.target); + * } else { + * console.log("Element left view", entry.target); + * } + * }); + * + * return ( + *
+ *

This element is being monitored

+ *
+ * ); + * }; + * ``` + */ +export const useOnInView = ( + onIntersectionChange: IntersectionChangeEffect, + { + threshold, + root, + rootMargin, + trackVisibility, + delay, + triggerOnce, + skip, + }: IntersectionEffectOptions = {}, +) => { + 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; + }, [onIntersectionChange]); + + // 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(); + } + }; + + if (element === observedElementRef.current) { + return observerCleanupRef.current; + } + + if (!element || skip) { + cleanupExisting(); + observedElementRef.current = null; + lastInViewRef.current = undefined; + return; + } + + cleanupExisting(); + + observedElementRef.current = element; + let destroyed = false; + + 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 }, + ); + if (triggerOnce && inView) { + stopObserving(); + } + }, + { + threshold, + root, + rootMargin, + trackVisibility, + delay, + } as IntersectionObserverInit, + ); + + 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; + observerCleanupRef.current = undefined; + lastInViewRef.current = undefined; + } + + observerCleanupRef.current = stopObserving; + + return observerCleanupRef.current; + }, + [ + Array.isArray(threshold) ? threshold.toString() : threshold, + root, + rootMargin, + trackVisibility, + delay, + triggerOnce, + skip, + ], + ); +}; 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 diff --git a/storybook/stories/useOnInView.story.tsx b/storybook/stories/useOnInView.story.tsx new file mode 100644 index 00000000..e1d58766 --- /dev/null +++ b/storybook/stories/useOnInView.story.tsx @@ -0,0 +1,150 @@ +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; + +type Story = StoryObj; + +const meta = { + title: "useOnInView Hook", + parameters: { + controls: { + expanded: true, + }, + }, + argTypes: { + ...argTypes, + }, + args: { + threshold: 0, + triggerOnce: false, + skip: false, + }, + render: UseOnInViewRender, +} satisfies Meta; + +export default meta; + +function UseOnInViewRender(rest: Props) { + const { options, error } = useValidateOptions(rest as IntersectionOptions); + + const { onChange, initialInView, fallbackInView, ...observerOptions } = + options; + + const effectOptions: IntersectionEffectOptions | undefined = error + ? undefined + : observerOptions; + + const [inView, setInView] = useState(false); + const [events, setEvents] = useState([]); + + const optionsKey = useMemo( + () => + JSON.stringify({ + 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, + ], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset when options change + useEffect(() => { + setEvents([]); + setInView(false); + }, [optionsKey]); + + 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) { + 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 TriggerOnce: Story = { + args: { + triggerOnce: true, + }, +}; + +export const SkipObserver: Story = { + args: { + skip: true, + }, +};