Skip to content

Commit efe59c8

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

File tree

12 files changed

+885
-25
lines changed

12 files changed

+885
-25
lines changed

README.md

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ to tell you when an element enters or leaves the viewport. Contains [Hooks](#use
1010

1111
## Features
1212

13-
- 🪝 **Hooks or Component API** - With `useInView` it's easier than ever to
14-
monitor elements
13+
- 🪝 **Hooks or Component API** - With `useInView` and `useOnInView` it's easier
14+
than ever to monitor elements
1515
- ⚡️ **Optimized performance** - Reuses Intersection Observer instances where
1616
possible
1717
- ⚙️ **Matches native API** - Intuitive to use
@@ -71,6 +71,72 @@ const Component = () => {
7171
};
7272
```
7373

74+
> **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.
75+
76+
### `useOnInView` hook
77+
78+
```js
79+
const inViewRef = useOnInView(
80+
(inView, entry) => {
81+
if (inView) {
82+
// Do something with the element that came into view
83+
console.log("Element is in view", entry.target);
84+
} else {
85+
console.log("Element left view", entry.target);
86+
}
87+
},
88+
options // Optional IntersectionObserver options
89+
);
90+
```
91+
92+
The `useOnInView` hook provides a more direct alternative to `useInView`. It
93+
takes a callback function and returns a ref that you can assign to the DOM
94+
element you want to monitor. Whenever the element enters or leaves the viewport,
95+
your callback will be triggered with the latest in-view state.
96+
97+
Key differences from `useInView`:
98+
- **No re-renders** - This hook doesn't update any state, making it ideal for
99+
performance-critical scenarios
100+
- **Direct element access** - Your callback receives the actual
101+
IntersectionObserverEntry with the `target` element
102+
- **Boolean-first callback** - The callback receives the current `inView`
103+
boolean as the first argument, matching the `onChange` signature from
104+
`useInView`
105+
- **Similar options** - Accepts all the same [options](#options) as `useInView`
106+
except `onChange`, `initialInView`, and `fallbackInView`
107+
108+
> **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).
109+
110+
```jsx
111+
import React from "react";
112+
import { useOnInView } from "react-intersection-observer";
113+
114+
const Component = () => {
115+
// Track when element appears without causing re-renders
116+
const trackingRef = useOnInView(
117+
(inView, entry) => {
118+
if (inView) {
119+
// Element is in view - perhaps log an impression
120+
console.log("Element appeared in view", entry.target);
121+
} else {
122+
console.log("Element left view", entry.target);
123+
}
124+
},
125+
{
126+
/* Optional options */
127+
threshold: 0.5,
128+
triggerOnce: true,
129+
},
130+
);
131+
132+
return (
133+
<div ref={trackingRef}>
134+
<h2>This element is being tracked without re-renders</h2>
135+
</div>
136+
);
137+
};
138+
```
139+
74140
### Render props
75141

76142
To use the `<InView>` component, you pass it a function. It will be called
@@ -87,18 +153,20 @@ state.
87153
```jsx
88154
import { InView } from "react-intersection-observer";
89155

90-
const Component = () => (
91-
<InView>
92-
{({ inView, ref, entry }) => (
156+
const Component = () => (
157+
<InView>
158+
{({ inView, ref, entry }) => (
93159
<div ref={ref}>
94160
<h2>{`Header inside viewport ${inView}.`}</h2>
95161
</div>
96162
)}
97163
</InView>
98164
);
99165

100-
export default Component;
101-
```
166+
export default Component;
167+
```
168+
169+
> **Note:** `<InView>` mirrors the hook behaviour—it suppresses the very first `false` notification so render props and `onChange` handlers only run after a genuine visibility change.
102170
103171
### Plain children
104172

@@ -145,6 +213,9 @@ Provide these as the options argument in the `useInView` hook or as props on the
145213
| **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. |
146214
| **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()` |
147215

216+
`useOnInView` accepts the same options as `useInView` except `onChange`,
217+
`initialInView`, and `fallbackInView`.
218+
148219
### InView Props
149220

150221
The **`<InView />`** component also accepts the following props:

docs/Recipes.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ export default LazyAnimation;
106106
## Track impressions
107107

108108
You can use `IntersectionObserver` to track when a user views your element, and
109-
fire an event on your tracking service.
109+
fire an event on your tracking service. Consider using the `useOnInView` to
110+
trigger changes via a callback.
110111

111112
- Set `triggerOnce`, to only trigger an event the first time the element enters
112113
the viewport.
@@ -115,22 +116,20 @@ fire an event on your tracking service.
115116
- Instead of `threshold`, you can use `rootMargin` to have a fixed amount be
116117
visible before triggering. Use a negative margin value, like `-100px 0px`, to
117118
have it go inwards. You can also use a percentage value, instead of pixels.
118-
- You can use the `onChange` callback to trigger the tracking.
119119

120120
```jsx
121121
import * as React from "react";
122-
import { useInView } from "react-intersection-observer";
122+
import { useOnInView } from "react-intersection-observer";
123123

124124
const TrackImpression = () => {
125-
const { ref } = useInView({
126-
triggerOnce: true,
127-
rootMargin: "-100px 0",
128-
onChange: (inView) => {
125+
const ref = useOnInView((inView) => {
129126
if (inView) {
130-
// Fire a tracking event to your tracking service of choice.
131-
dataLayer.push("Section shown"); // Here's a GTM dataLayer push
127+
// Fire a tracking event to your tracking service of choice.
128+
dataLayer.push("Section shown"); // Here's a GTM dataLayer push
132129
}
133-
},
130+
}, {
131+
triggerOnce: true,
132+
rootMargin: "-100px 0",
134133
});
135134

136135
return (

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,25 @@
9494
"path": "dist/index.mjs",
9595
"name": "InView",
9696
"import": "{ InView }",
97-
"limit": "1.8 kB"
97+
"limit": "1.5 kB"
9898
},
9999
{
100100
"path": "dist/index.mjs",
101101
"name": "useInView",
102102
"import": "{ useInView }",
103103
"limit": "1.3 kB"
104104
},
105+
{
106+
"path": "dist/index.mjs",
107+
"name": "useOnInView",
108+
"import": "{ useOnInView }",
109+
"limit": "1.1 kB"
110+
},
105111
{
106112
"path": "dist/index.mjs",
107113
"name": "observe",
108114
"import": "{ observe }",
109-
"limit": "1 kB"
115+
"limit": "0.9 kB"
110116
}
111117
],
112118
"peerDependencies": {

src/InView.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@ export class InView extends React.Component<
6868
> {
6969
node: Element | null = null;
7070
_unobserveCb: (() => void) | null = null;
71+
lastInView: boolean | undefined;
7172

7273
constructor(props: IntersectionObserverProps | PlainChildrenProps) {
7374
super(props);
7475
this.state = {
7576
inView: !!props.initialInView,
7677
entry: undefined,
7778
};
79+
this.lastInView = props.initialInView;
7880
}
7981

8082
componentDidMount() {
@@ -112,6 +114,9 @@ export class InView extends React.Component<
112114
fallbackInView,
113115
} = this.props;
114116

117+
if (this.lastInView === undefined) {
118+
this.lastInView = this.props.initialInView;
119+
}
115120
this._unobserveCb = observe(
116121
this.node,
117122
this.handleChange,
@@ -142,6 +147,7 @@ export class InView extends React.Component<
142147
if (!node && !this.props.triggerOnce && !this.props.skip) {
143148
// Reset the state if we get a new node, and we aren't ignoring updates
144149
this.setState({ inView: !!this.props.initialInView, entry: undefined });
150+
this.lastInView = this.props.initialInView;
145151
}
146152
}
147153

@@ -150,6 +156,14 @@ export class InView extends React.Component<
150156
};
151157

152158
handleChange = (inView: boolean, entry: IntersectionObserverEntry) => {
159+
const previousInView = this.lastInView;
160+
this.lastInView = inView;
161+
162+
// Ignore the very first `false` notification so consumers only hear about actual state changes.
163+
if (previousInView === undefined && !inView) {
164+
return;
165+
}
166+
153167
if (inView && this.props.triggerOnce) {
154168
// If `triggerOnce` is true, we should stop observing the element.
155169
this.unobserve();

src/__tests__/InView.test.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ test("Should render <InView /> intersecting", () => {
1313
);
1414

1515
mockAllIsIntersecting(false);
16-
expect(callback).toHaveBeenLastCalledWith(
17-
false,
18-
expect.objectContaining({ isIntersecting: false }),
19-
);
16+
expect(callback).not.toHaveBeenCalled();
2017

2118
mockAllIsIntersecting(true);
2219
expect(callback).toHaveBeenLastCalledWith(
2320
true,
2421
expect.objectContaining({ isIntersecting: true }),
2522
);
23+
24+
mockAllIsIntersecting(false);
25+
expect(callback).toHaveBeenLastCalledWith(
26+
false,
27+
expect.objectContaining({ isIntersecting: false }),
28+
);
2629
});
2730

2831
test("should render plain children", () => {

src/__tests__/hooks.test.tsx renamed to src/__tests__/useInView.test.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ test("should trigger onChange", () => {
112112
const onChange = vi.fn();
113113
render(<HookComponent options={{ onChange }} />);
114114

115+
mockAllIsIntersecting(false);
116+
expect(onChange).not.toHaveBeenCalled();
117+
115118
mockAllIsIntersecting(true);
116119
expect(onChange).toHaveBeenLastCalledWith(
117120
true,
@@ -186,12 +189,12 @@ const SwitchHookComponent = ({
186189
<>
187190
<div
188191
data-testid="item-1"
189-
data-inview={!toggle && inView}
192+
data-inview={(!toggle && inView).toString()}
190193
ref={!toggle && !unmount ? ref : undefined}
191194
/>
192195
<div
193196
data-testid="item-2"
194-
data-inview={!!toggle && inView}
197+
data-inview={(!!toggle && inView).toString()}
195198
ref={toggle && !unmount ? ref : undefined}
196199
/>
197200
</>

0 commit comments

Comments
 (0)