diff --git a/README.md b/README.md
index 242c9201..ea376dd2 100644
--- a/README.md
+++ b/README.md
@@ -236,6 +236,62 @@ You can read more about this on these links:
- [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372)
- [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465)
+### `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` and `initialInView`
+
+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
+
+ );
+};
+```
+
## Testing
> [!TIP]
diff --git a/biome.json b/biome.json
index 89230f14..4cd61786 100644
--- a/biome.json
+++ b/biome.json
@@ -30,6 +30,9 @@
},
"a11y": {
"noSvgWithoutTitle": "off"
+ },
+ "correctness": {
+ "useExhaustiveDependencies": "off"
}
}
},
diff --git a/package.json b/package.json
index 15f316b8..27f1770a 100644
--- a/package.json
+++ b/package.json
@@ -108,8 +108,8 @@
}
],
"peerDependencies": {
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.4",
diff --git a/src/InView.tsx b/src/InView.tsx
index 0c27ccfa..b48930b0 100644
--- a/src/InView.tsx
+++ b/src/InView.tsx
@@ -103,29 +103,17 @@ export class InView extends React.Component<
observeNode() {
if (!this.node || this.props.skip) return;
- const {
+ const { threshold, root, rootMargin, trackVisibility, delay } = this.props;
+
+ this._unobserveCb = observe(this.node, this.handleChange, {
threshold,
root,
rootMargin,
+ // @ts-ignore
trackVisibility,
+ // @ts-ignore
delay,
- fallbackInView,
- } = this.props;
-
- this._unobserveCb = observe(
- this.node,
- this.handleChange,
- {
- threshold,
- root,
- rootMargin,
- // @ts-ignore
- trackVisibility,
- // @ts-ignore
- delay,
- },
- fallbackInView,
- );
+ });
}
unobserve() {
@@ -184,7 +172,6 @@ export class InView extends React.Component<
trackVisibility,
delay,
initialInView,
- fallbackInView,
...props
} = this.props as PlainChildrenProps;
diff --git a/src/__tests__/InView.test.tsx b/src/__tests__/InView.test.tsx
index 29370559..577fd28c 100644
--- a/src/__tests__/InView.test.tsx
+++ b/src/__tests__/InView.test.tsx
@@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
import { userEvent } from "@vitest/browser/context";
import React from "react";
import { InView } from "../InView";
-import { defaultFallbackInView } from "../observe";
import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils";
test("Should render intersecting", () => {
@@ -157,62 +156,3 @@ test("plain children should not catch bubbling onChange event", async () => {
await userEvent.type(input, "changed value");
expect(onChange).not.toHaveBeenCalled();
});
-
-test("should render with fallback", () => {
- const cb = vi.fn();
- // @ts-ignore
- window.IntersectionObserver = undefined;
- render(
-
- Inner
- ,
- );
- expect(cb).toHaveBeenLastCalledWith(
- true,
- expect.objectContaining({ isIntersecting: true }),
- );
-
- render(
-
- Inner
- ,
- );
- expect(cb).toHaveBeenLastCalledWith(
- false,
- expect.objectContaining({ isIntersecting: false }),
- );
-
- expect(() => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- render(Inner);
- // @ts-ignore
- console.error.mockRestore();
- }).toThrow();
-});
-
-test("should render with global fallback", () => {
- const cb = vi.fn();
- // @ts-ignore
- window.IntersectionObserver = undefined;
- defaultFallbackInView(true);
- render(Inner);
- expect(cb).toHaveBeenLastCalledWith(
- true,
- expect.objectContaining({ isIntersecting: true }),
- );
-
- defaultFallbackInView(false);
- render(Inner);
- expect(cb).toHaveBeenLastCalledWith(
- false,
- expect.objectContaining({ isIntersecting: false }),
- );
-
- defaultFallbackInView(undefined);
- expect(() => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- render(Inner);
- // @ts-ignore
- console.error.mockRestore();
- }).toThrow();
-});
diff --git a/src/__tests__/hooks.test.tsx b/src/__tests__/hooks.test.tsx
index 7cee2f6f..b6623691 100644
--- a/src/__tests__/hooks.test.tsx
+++ b/src/__tests__/hooks.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import React, { useCallback } from "react";
-import { type IntersectionOptions, defaultFallbackInView } from "../index";
+import type { IntersectionOptions } from "../index";
import {
destroyIntersectionMocking,
intersectionMockInstance,
@@ -235,9 +235,7 @@ test("should handle ref removed", () => {
const MergeRefsComponent = ({ options }: { options?: IntersectionOptions }) => {
const [inViewRef, inView] = useInView(options);
const setRef = useCallback(
- (node: Element | null) => {
- inViewRef(node);
- },
+ (node: Element | null) => inViewRef(node),
[inViewRef],
);
@@ -263,9 +261,8 @@ const MultipleHookComponent = ({
const mergedRefs = useCallback(
(node: Element | null) => {
- ref1(node);
- ref2(node);
- ref3(node);
+ const cleanup = [ref1(node), ref2(node), ref3(node)];
+ return () => cleanup.forEach((fn) => fn());
},
[ref1, ref2, ref3],
);
@@ -342,51 +339,6 @@ test("should set intersection ratio as the largest threshold smaller than trigge
screen.getByText(/intersectionRatio: 0.5/);
});
-test("should handle fallback if unsupported", () => {
- destroyIntersectionMocking();
- // @ts-ignore
- window.IntersectionObserver = undefined;
- const { rerender } = render(
- ,
- );
- screen.getByText("true");
-
- rerender();
- screen.getByText("false");
-
- expect(() => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- rerender();
- // @ts-ignore
- console.error.mockRestore();
- }).toThrowErrorMatchingInlineSnapshot(
- `[TypeError: IntersectionObserver is not a constructor]`,
- );
-});
-
-test("should handle defaultFallbackInView if unsupported", () => {
- destroyIntersectionMocking();
- // @ts-ignore
- window.IntersectionObserver = undefined;
- defaultFallbackInView(true);
- const { rerender } = render();
- screen.getByText("true");
-
- defaultFallbackInView(false);
- rerender();
- screen.getByText("false");
-
- defaultFallbackInView(undefined);
- expect(() => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- rerender();
- // @ts-ignore
- console.error.mockRestore();
- }).toThrowErrorMatchingInlineSnapshot(
- `[TypeError: IntersectionObserver is not a constructor]`,
- );
-});
-
test("should restore the browser IntersectionObserver", () => {
expect(vi.isMockFunction(window.IntersectionObserver)).toBe(true);
destroyIntersectionMocking();
diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx
new file mode 100644
index 00000000..6f767720
--- /dev/null
+++ b/src/__tests__/useOnInView.test.tsx
@@ -0,0 +1,610 @@
+import { render } from "@testing-library/react";
+import React, { useCallback } 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] = React.useState(false);
+ const [callCount, setCallCount] = React.useState(0);
+ const [cleanupCount, setCleanupCount] = React.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] = React.useState(true);
+ const [inView, setInView] = React.useState(false);
+
+ React.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] = React.useState(0);
+ const inViewRef = useOnInView(() => {
+ setCallCount((prev) => prev + 1);
+ }, options);
+
+ return (
+
+ );
+};
+
+const ThresholdTriggerComponent = ({
+ options,
+}: {
+ options?: IntersectionEffectOptions;
+}) => {
+ const [triggerCount, setTriggerCount] = React.useState(0);
+ const [cleanupCount, setCleanupCount] = React.useState(0);
+ const [lastRatio, setLastRatio] = React.useState(null);
+ const [triggeredThresholds, setTriggeredThresholds] = React.useState<
+ number[]
+ >([]);
+
+ 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] = React.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] = React.useState(false);
+ const [inView2, setInView2] = React.useState(false);
+ const [inView3, setInView3] = React.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] = React.useState(0);
+ const [inView, setInView] = React.useState(false);
+ const [observerDestroyed, setObserverDestroyed] = React.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] = React.useState(false);
+ const [observerDestroyed, setObserverDestroyed] = React.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 cec16ed2..8e6398f9 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -3,13 +3,14 @@
import type * as React from "react";
export { InView } from "./InView";
export { useInView } from "./useInView";
-export { observe, defaultFallbackInView } from "./observe";
+export { useOnInView } from "./useOnInView";
+export { observe } from "./observe";
type Omit = Pick>;
-export type ObserverInstanceCallback = (
+export type ObserverInstanceCallback = (
inView: boolean,
- entry: IntersectionObserverEntry,
+ entry: IntersectionObserverEntry & { target: TElement },
) => void;
interface RenderProps {
@@ -19,7 +20,7 @@ interface RenderProps {
ref: React.RefObject | ((node?: Element | null) => void);
}
-export interface IntersectionOptions extends IntersectionObserverInit {
+export interface IntersectionBaseOptions extends IntersectionObserverInit {
/** The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.*/
root?: Element | Document | null;
/** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */
@@ -30,16 +31,22 @@ export interface IntersectionOptions extends IntersectionObserverInit {
triggerOnce?: boolean;
/** Skip assigning the observer to the `ref` */
skip?: boolean;
- /** 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. */
- initialInView?: boolean;
- /** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */
- fallbackInView?: boolean;
/** IntersectionObserver v2 - Track the actual visibility of the element */
trackVisibility?: boolean;
/** IntersectionObserver v2 - Set a minimum delay between notifications */
delay?: number;
+}
+
+export interface IntersectionOptions extends IntersectionBaseOptions {
/** Call this function whenever the in view state changes */
onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void;
+ /** 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. */
+ initialInView?: boolean;
+}
+
+export interface IntersectionEffectOptions extends IntersectionBaseOptions {
+ /** When to trigger the inView callback - default: "enter" */
+ trigger?: "enter" | "leave";
}
export interface IntersectionObserverProps extends IntersectionOptions {
@@ -74,11 +81,25 @@ export type PlainChildrenProps = IntersectionOptions & {
* The Hook response supports both array and object destructing
*/
export type InViewHookResponse = [
- (node?: Element | null) => void,
+ (node?: Element | null) => () => void,
boolean,
IntersectionObserverEntry | undefined,
] & {
- ref: (node?: Element | null) => void;
+ ref: (node?: Element | null) => () => void;
inView: boolean;
entry?: IntersectionObserverEntry;
};
+
+/**
+ * The callback called by the useOnInView hook once the element is in view
+ *
+ * Allows to return a cleanup function that will be called when the element goes out of view or when the observer is destroyed
+ */
+export type IntersectionChangeEffect = (
+ entry: IntersectionObserverEntry & { target: TElement },
+ destroyObserver: () => void,
+) => // biome-ignore lint/suspicious/noConfusingVoidType: Allow no return statement
+ | void
+ | undefined
+ /** Entry is defined except when the element is unmounting */
+ | ((entry?: IntersectionObserverEntry | undefined) => void);
diff --git a/src/observe.ts b/src/observe.ts
index 5e0a79d3..a27142a4 100644
--- a/src/observe.ts
+++ b/src/observe.ts
@@ -12,18 +12,6 @@ const observerMap = new Map<
const RootIds: WeakMap = new WeakMap();
let rootId = 0;
-let unsupportedValue: boolean | undefined = undefined;
-
-/**
- * What should be the default behavior if the IntersectionObserver is unsupported?
- * Ideally the polyfill has been loaded, you can have the following happen:
- * - `undefined`: Throw an error
- * - `true` or `false`: Set the `inView` value to this regardless of intersection state
- * **/
-export function defaultFallbackInView(inView: boolean | undefined) {
- unsupportedValue = inView;
-}
-
/**
* Generate a unique ID for the root element
* @param root
@@ -83,9 +71,16 @@ function createObserver(options: IntersectionObserverInit) {
entry.isVisible = inView;
}
- elements.get(entry.target)?.forEach((callback) => {
- callback(inView, entry);
- });
+ elements
+ .get(entry.target)
+ // slice creates a shallow copy of the array
+ // otherwise an `unobserve` call from a callback
+ // would modifiy `elements` and therefore some
+ // callbacks in the forEach loop would be skipped
+ ?.slice()
+ .forEach((callback) => {
+ callback(inView, entry);
+ });
});
}, options);
@@ -112,49 +107,32 @@ function createObserver(options: IntersectionObserverInit) {
* @param element - DOM Element to observe
* @param callback - Callback function to trigger when intersection status changes
* @param options - Intersection Observer options
- * @param fallbackInView - Fallback inView value.
* @return Function - Cleanup function that should be triggered to unregister the observer
*/
-export function observe(
- element: Element,
- callback: ObserverInstanceCallback,
+export function observe(
+ element: TElement,
+ callback: ObserverInstanceCallback,
options: IntersectionObserverInit = {},
- fallbackInView = unsupportedValue,
) {
- if (
- typeof window.IntersectionObserver === "undefined" &&
- fallbackInView !== undefined
- ) {
- const bounds = element.getBoundingClientRect();
- callback(fallbackInView, {
- isIntersecting: fallbackInView,
- target: element,
- intersectionRatio:
- typeof options.threshold === "number" ? options.threshold : 0,
- time: 0,
- boundingClientRect: bounds,
- intersectionRect: bounds,
- rootBounds: bounds,
- });
- return () => {
- // Nothing to cleanup
- };
- }
// An observer with the same options can be reused, so lets use this fact
const { id, observer, elements } = createObserver(options);
// Register the callback listener for this element
- const callbacks = elements.get(element) || [];
- if (!elements.has(element)) {
- elements.set(element, callbacks);
+ let callbacks = elements.get(element);
+ if (!callbacks) {
+ callbacks = [];
+ elements.set(element, callbacks as ObserverInstanceCallback[]);
+ observer.observe(element);
}
- callbacks.push(callback);
- observer.observe(element);
+ callbacks.push(callback as ObserverInstanceCallback);
return function unobserve() {
// Remove the callback from the callback list
- callbacks.splice(callbacks.indexOf(callback), 1);
+ callbacks.splice(
+ callbacks.indexOf(callback as ObserverInstanceCallback),
+ 1,
+ );
if (callbacks.length === 0) {
// No more callback exists for element, so destroy it
diff --git a/src/useInView.tsx b/src/useInView.tsx
index 4eda4969..60b21af3 100644
--- a/src/useInView.tsx
+++ b/src/useInView.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
import type { InViewHookResponse, IntersectionOptions } from "./index";
-import { observe } from "./observe";
+import { useOnInView } from "./useOnInView";
type State = {
inView: boolean;
@@ -33,106 +33,90 @@ type State = {
* };
* ```
*/
-export function useInView({
- threshold,
- delay,
- trackVisibility,
- rootMargin,
- root,
- triggerOnce,
- skip,
- initialInView,
- fallbackInView,
- onChange,
-}: IntersectionOptions = {}): InViewHookResponse {
- const [ref, setRef] = React.useState(null);
- const callback = React.useRef(onChange);
+export function useInView(
+ options: IntersectionOptions = {},
+): InViewHookResponse {
+ const {
+ threshold,
+ root,
+ rootMargin,
+ trackVisibility,
+ delay,
+ triggerOnce,
+ skip,
+ initialInView,
+ } = options;
+
+ // State for tracking inView status and the IntersectionObserverEntry
const [state, setState] = React.useState({
inView: !!initialInView,
entry: undefined,
});
- // Store the onChange callback in a `ref`, so we can access the latest instance
- // inside the `useEffect`, but without triggering a rerender.
- callback.current = onChange;
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency
- React.useEffect(
- () => {
- // Ensure we have node ref, and that we shouldn't skip observing
- if (skip || !ref) return;
+ // The cleanup function is created when the element gets in view and therefore
+ // needs a ref to access the latest options
+ const latestOptions = React.useRef(options);
+ latestOptions.current = options;
- let unobserve: (() => void) | undefined;
- unobserve = observe(
- ref,
- (inView, entry) => {
- setState({
- inView,
- entry,
- });
- if (callback.current) callback.current(inView, entry);
+ // Create the ref tracking function using useOnInView
+ const refCallback = useOnInView(
+ // Combined callback - updates state, calls onChange, and returns cleanup if needed
+ (entry) => {
+ const { onChange } = latestOptions.current;
+ // The callback is triggered when the element enters or leaves the viewport
+ // depending on trigger (which is defined by initialInView)
+ // If initialInView is false we wait for the element to enter the viewport
+ // in that case we set inView to true
+ // If initialInView is true we wait for the element to leave the viewport
+ // in that case we set inView to false
+ const inView = !initialInView;
+ setState({ inView, entry });
- if (entry.isIntersecting && triggerOnce && unobserve) {
- // If it should only trigger once, unobserve the element after it's inView
- unobserve();
- unobserve = undefined;
- }
- },
- {
- root,
- rootMargin,
- threshold,
- // @ts-ignore
- trackVisibility,
- // @ts-ignore
- delay,
- },
- fallbackInView,
- );
+ // Call the external onChange if provided
+ // entry is undefined only if this is triggered by initialInView
+ if (onChange) {
+ onChange(inView, entry);
+ }
- return () => {
- if (unobserve) {
- unobserve();
- }
- };
+ return triggerOnce
+ ? // If triggerOnce is true no reset state is done in the cleanup
+ // this allows destroying the observer as soon as the element is inView
+ undefined
+ : // Return cleanup function that will run when element is removed or goes out of view
+ (entry) => {
+ const { onChange, skip } = latestOptions.current;
+ // Call the external onChange if provided
+ // entry is undefined if the element is getting unmounted
+ if (onChange && entry) {
+ onChange(!inView, entry);
+ }
+ // should not reset current state if changing skip
+ if (!skip) {
+ setState({
+ inView: !inView,
+ entry: undefined,
+ });
+ }
+ };
},
- // We break the rule here, because we aren't including the actual `threshold` variable
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [
- // If the threshold is an array, convert it to a string, so it won't change between renders.
- Array.isArray(threshold) ? threshold.toString() : threshold,
- ref,
+ {
+ threshold,
root,
rootMargin,
- triggerOnce,
- skip,
+ // @ts-ignore
trackVisibility,
- fallbackInView,
+ // @ts-ignore
delay,
- ],
+ triggerOnce,
+ skip,
+ trigger: initialInView ? "leave" : undefined,
+ },
);
- const entryTarget = state.entry?.target;
- const previousEntryTarget = React.useRef(undefined);
- if (
- !ref &&
- entryTarget &&
- !triggerOnce &&
- !skip &&
- previousEntryTarget.current !== entryTarget
- ) {
- // If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`)
- // This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView
- previousEntryTarget.current = entryTarget;
- setState({
- inView: !!initialInView,
- entry: undefined,
- });
- }
-
- const result = [setRef, state.inView, state.entry] as InViewHookResponse;
+ // Build the result with the same API as the original hook
+ const result = [refCallback, state.inView, state.entry] as InViewHookResponse;
- // Support object destructuring, by adding the specific values.
+ // Add named properties for object destructuring support
result.ref = result[0];
result.inView = result[1];
result.entry = result[2];
diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx
new file mode 100644
index 00000000..c80d2066
--- /dev/null
+++ b/src/useOnInView.tsx
@@ -0,0 +1,136 @@
+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 = {},
+) => {
+ // Store the onIntersectionChange in a ref to avoid triggering recreation
+ const onIntersectionChangeRef = React.useRef(onIntersectionChange);
+ // https://react.dev/reference/react/useRef#caveats
+ // > Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable.
+ //
+ // With useInsertionEffect, ref.current can be updated after a successful render
+ // from https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts
+ React.useInsertionEffect(() => {
+ onIntersectionChangeRef.current = onIntersectionChange;
+ }, [onIntersectionChange]);
+
+ return React.useCallback(
+ (element: TElement | undefined | null) => {
+ if (!element || skip) {
+ return;
+ }
+
+ let callbackCleanup:
+ | undefined
+ | ReturnType>;
+
+ // trigger "enter": intersectionsStateTrigger = true
+ // trigger "leave": intersectionsStateTrigger = false
+ const intersectionsStateTrigger = trigger !== "leave";
+
+ const destroyInviewObserver = observe(
+ element,
+ (inView, entry) => {
+ // Cleanup the previous callback if it exists
+ if (callbackCleanup) {
+ callbackCleanup(entry);
+ callbackCleanup = undefined;
+ // If the callbackCleanup was called and triggerOnce is true
+ // the observer can be destroyed immediately after the callback is called
+ if (triggerOnce) {
+ destroyInviewObserver();
+ return;
+ }
+ }
+ // Call the callback when the element is in view (if trigger is "enter")
+ // Call the callback when the element is out of view (if trigger is "leave")
+ if (inView === intersectionsStateTrigger) {
+ callbackCleanup = onIntersectionChangeRef.current(
+ entry,
+ destroyInviewObserver,
+ );
+ // if there is no cleanup function returned from the callback
+ // and triggerOnce is true, the observer can be destroyed immediately
+ if (triggerOnce && !callbackCleanup) {
+ destroyInviewObserver();
+ }
+ }
+ },
+ {
+ threshold,
+ root,
+ rootMargin,
+ // @ts-ignore
+ trackVisibility,
+ // @ts-ignore
+ delay,
+ },
+ );
+ // Return cleanup function for React 19's ref callback
+ return () => {
+ destroyInviewObserver();
+ callbackCleanup?.();
+ };
+ },
+ // We break the rule here, because we aren't including the actual `threshold` variable
+ [
+ // Convert threshold array to string for stable dependency
+ Array.isArray(threshold) ? threshold.toString() : threshold,
+ root,
+ rootMargin,
+ trackVisibility,
+ delay,
+ triggerOnce,
+ skip,
+ trigger,
+ ],
+ );
+};