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,
+ },
+};