Skip to content

Commit d9523b6

Browse files
committed
rename useOnInViewChanged to useOnInView - refactor useOnInView initialInView option to trigger
1 parent 7c25f82 commit d9523b6

File tree

5 files changed

+128
-98
lines changed

5 files changed

+128
-98
lines changed

README.md

+13-11
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,13 @@ You can read more about this on these links:
236236
- [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372)
237237
- [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465)
238238
239-
### `useOnInViewChanged` hook
239+
### `useOnInView` hook
240240
241241
```js
242-
const inViewRef = useOnInViewChanged(
242+
const inViewRef = useOnInView(
243243
(enterEntry) => {
244244
// Do something with the element that came into view
245-
console.log('Element is in view', enterEntry?.element);
245+
console.log('Element is in view', enterEntry?.target);
246246

247247
// Optionally return a cleanup function
248248
return (exitEntry) => {
@@ -253,23 +253,25 @@ const inViewRef = useOnInViewChanged(
253253
);
254254
```
255255
256-
The `useOnInViewChanged` 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.
256+
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.
257257
258258
Key differences from `useInView`:
259259
- **No re-renders** - This hook doesn't update any state, making it ideal for performance-critical scenarios
260260
- **Direct element access** - Your callback receives the actual DOM element and the IntersectionObserverEntry
261-
- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport or is unmounted
262-
- **Same options** - Accepts all the same [options](#options) as `useInView` except `onChange`
261+
- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport
262+
- **Similar options** - Accepts all the same [options](#options) as `useInView` except `onChange` and `initialInView`
263+
264+
The `trigger` option allows to listen for the element entering the viewport or leaving the viewport. The default is `enter`.
263265
264266
```jsx
265267
import React from "react";
266-
import { useOnInViewChanged } from "react-intersection-observer";
268+
import { useOnInView } from "react-intersection-observer";
267269

268270
const Component = () => {
269271
// Track when element appears without causing re-renders
270-
const trackingRef = useOnInViewChanged((element, entry) => {
272+
const trackingRef = useOnInView((entry) => {
271273
// Element is in view - perhaps log an impression
272-
console.log("Element appeared in view");
274+
console.log("Element appeared in view", entry.target);
273275

274276
// Return optional cleanup function
275277
return () => {
@@ -278,6 +280,8 @@ const Component = () => {
278280
}, {
279281
/* Optional options */
280282
threshold: 0.5,
283+
trigger: "enter",
284+
triggerOnce: true,
281285
});
282286

283287
return (
@@ -286,8 +290,6 @@ const Component = () => {
286290
</div>
287291
);
288292
};
289-
290-
export default Component;
291293
```
292294
293295
## Testing

src/__tests__/useOnInViewChanged.test.tsx src/__tests__/useOnInView.test.tsx

+43-22
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
import { render } from "@testing-library/react";
22
import React, { useCallback } from "react";
3-
import type { IntersectionListenerOptions } from "../index";
3+
import type { IntersectionEffectOptions } from "..";
44
import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils";
5-
import { useOnInViewChanged } from "../useOnInViewChanged";
5+
import { useOnInView } from "../useOnInView";
66

77
const OnInViewChangedComponent = ({
88
options,
99
unmount,
1010
}: {
11-
options?: IntersectionListenerOptions;
11+
options?: IntersectionEffectOptions;
1212
unmount?: boolean;
1313
}) => {
1414
const [inView, setInView] = React.useState(false);
1515
const [callCount, setCallCount] = React.useState(0);
1616
const [cleanupCount, setCleanupCount] = React.useState(0);
1717

18-
const inViewRef = useOnInViewChanged((entry) => {
19-
setInView(entry ? entry.isIntersecting : false);
18+
const inViewRef = useOnInView((entry) => {
19+
setInView(entry.isIntersecting);
2020
setCallCount((prev) => prev + 1);
21-
2221
// Return cleanup function
2322
return (cleanupEntry) => {
2423
setCleanupCount((prev) => prev + 1);
@@ -44,7 +43,7 @@ const OnInViewChangedComponent = ({
4443
const LazyOnInViewChangedComponent = ({
4544
options,
4645
}: {
47-
options?: IntersectionListenerOptions;
46+
options?: IntersectionEffectOptions;
4847
}) => {
4948
const [isLoading, setIsLoading] = React.useState(true);
5049
const [inView, setInView] = React.useState(false);
@@ -53,7 +52,7 @@ const LazyOnInViewChangedComponent = ({
5352
setIsLoading(false);
5453
}, []);
5554

56-
const inViewRef = useOnInViewChanged((entry) => {
55+
const inViewRef = useOnInView((entry) => {
5756
setInView(entry ? entry.isIntersecting : false);
5857
return () => setInView(false);
5958
}, options);
@@ -71,11 +70,11 @@ const OnInViewChangedComponentWithoutClenaup = ({
7170
options,
7271
unmount,
7372
}: {
74-
options?: IntersectionListenerOptions;
73+
options?: IntersectionEffectOptions;
7574
unmount?: boolean;
7675
}) => {
7776
const [callCount, setCallCount] = React.useState(0);
78-
const inViewRef = useOnInViewChanged(() => {
77+
const inViewRef = useOnInView(() => {
7978
setCallCount((prev) => prev + 1);
8079
}, options);
8180

@@ -88,7 +87,7 @@ const OnInViewChangedComponentWithoutClenaup = ({
8887
);
8988
};
9089

91-
test("should create a hook with useOnInViewChanged", () => {
90+
test("should create a hook with useOnInView", () => {
9291
const { getByTestId } = render(<OnInViewChangedComponent />);
9392
const wrapper = getByTestId("wrapper");
9493
const instance = intersectionMockInstance(wrapper);
@@ -106,7 +105,7 @@ test("should create a hook with array threshold", () => {
106105
expect(instance.observe).toHaveBeenCalledWith(wrapper);
107106
});
108107

109-
test("should create a lazy hook with useOnInViewChanged", () => {
108+
test("should create a lazy hook with useOnInView", () => {
110109
const { getByTestId } = render(<LazyOnInViewChangedComponent />);
111110
const wrapper = getByTestId("wrapper");
112111
const instance = intersectionMockInstance(wrapper);
@@ -149,16 +148,38 @@ test("should respect threshold values", () => {
149148
expect(wrapper.getAttribute("data-inview")).toBe("true");
150149
});
151150

152-
test("should call callback with initialInView", () => {
151+
test("should call callback with trigger: leave", () => {
153152
const { getByTestId } = render(
154-
<OnInViewChangedComponent options={{ initialInView: true }} />,
153+
<OnInViewChangedComponent options={{ trigger: "leave" }} />,
155154
);
156155
const wrapper = getByTestId("wrapper");
157156

158-
// initialInView should have triggered the callback once
157+
mockAllIsIntersecting(false);
158+
// Should call callback
159159
expect(wrapper.getAttribute("data-call-count")).toBe("1");
160160

161+
mockAllIsIntersecting(true);
162+
// Should call cleanup
163+
expect(wrapper.getAttribute("data-cleanup-count")).toBe("1");
164+
});
165+
166+
test("should call callback with trigger: leave and triggerOnce is true", () => {
167+
const { getByTestId } = render(
168+
<OnInViewChangedComponent
169+
options={{ trigger: "leave", triggerOnce: true }}
170+
/>,
171+
);
172+
const wrapper = getByTestId("wrapper");
173+
174+
mockAllIsIntersecting(true);
175+
// initialInView should have triggered the callback once
176+
expect(wrapper.getAttribute("data-call-count")).toBe("0");
177+
161178
mockAllIsIntersecting(false);
179+
// Should call callback
180+
expect(wrapper.getAttribute("data-call-count")).toBe("1");
181+
182+
mockAllIsIntersecting(true);
162183
// Should call cleanup
163184
expect(wrapper.getAttribute("data-cleanup-count")).toBe("1");
164185
});
@@ -229,10 +250,10 @@ test("should handle ref changes", () => {
229250
// Test for merging refs
230251
const MergeRefsComponent = ({
231252
options,
232-
}: { options?: IntersectionListenerOptions }) => {
253+
}: { options?: IntersectionEffectOptions }) => {
233254
const [inView, setInView] = React.useState(false);
234255

235-
const inViewRef = useOnInViewChanged((entry) => {
256+
const inViewRef = useOnInView((entry) => {
236257
setInView(entry ? entry.isIntersecting : false);
237258
return () => setInView(false);
238259
}, options);
@@ -258,22 +279,22 @@ test("should handle merged refs", () => {
258279
// Test multiple callbacks on the same element
259280
const MultipleCallbacksComponent = ({
260281
options,
261-
}: { options?: IntersectionListenerOptions }) => {
282+
}: { options?: IntersectionEffectOptions }) => {
262283
const [inView1, setInView1] = React.useState(false);
263284
const [inView2, setInView2] = React.useState(false);
264285
const [inView3, setInView3] = React.useState(false);
265286

266-
const ref1 = useOnInViewChanged((entry) => {
287+
const ref1 = useOnInView((entry) => {
267288
setInView1(entry ? entry.isIntersecting : false);
268289
return () => setInView1(false);
269290
}, options);
270291

271-
const ref2 = useOnInViewChanged((entry) => {
292+
const ref2 = useOnInView((entry) => {
272293
setInView2(entry ? entry.isIntersecting : false);
273294
return () => setInView2(false);
274295
}, options);
275296

276-
const ref3 = useOnInViewChanged((entry) => {
297+
const ref3 = useOnInView((entry) => {
277298
setInView3(entry ? entry.isIntersecting : false);
278299
return () => setInView3(false);
279300
});
@@ -316,7 +337,7 @@ test("should pass the element to the callback", () => {
316337
let capturedElement: Element | undefined;
317338

318339
const ElementTestComponent = () => {
319-
const inViewRef = useOnInViewChanged((entry) => {
340+
const inViewRef = useOnInView((entry) => {
320341
capturedElement = entry?.target;
321342
return undefined;
322343
});

src/index.tsx

+14-9
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type * as React from "react";
44
export { InView } from "./InView";
55
export { useInView } from "./useInView";
6-
export { useOnInViewChanged } from "./useOnInViewChanged";
6+
export { useOnInView } from "./useOnInView";
77
export { observe } from "./observe";
88

99
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
@@ -20,7 +20,7 @@ interface RenderProps {
2020
ref: React.RefObject<any> | ((node?: Element | null) => void);
2121
}
2222

23-
export interface IntersectionListenerOptions extends IntersectionObserverInit {
23+
export interface IntersectionBaseOptions extends IntersectionObserverInit {
2424
/** 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.*/
2525
root?: Element | Document | null;
2626
/** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */
@@ -31,17 +31,22 @@ export interface IntersectionListenerOptions extends IntersectionObserverInit {
3131
triggerOnce?: boolean;
3232
/** Skip assigning the observer to the `ref` */
3333
skip?: boolean;
34-
/** 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. */
35-
initialInView?: boolean;
3634
/** IntersectionObserver v2 - Track the actual visibility of the element */
3735
trackVisibility?: boolean;
3836
/** IntersectionObserver v2 - Set a minimum delay between notifications */
3937
delay?: number;
4038
}
4139

42-
export interface IntersectionOptions extends IntersectionListenerOptions {
40+
export interface IntersectionOptions extends IntersectionBaseOptions {
4341
/** Call this function whenever the in view state changes */
4442
onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void;
43+
/** 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. */
44+
initialInView?: boolean;
45+
}
46+
47+
export interface IntersectionEffectOptions extends IntersectionBaseOptions {
48+
/** When to trigger the inView callback - default: "enter" */
49+
trigger?: "enter" | "leave";
4550
}
4651

4752
export interface IntersectionObserverProps extends IntersectionOptions {
@@ -86,14 +91,14 @@ export type InViewHookResponse = [
8691
};
8792

8893
/**
89-
* The callback called by the useOnInViewChanged hook once the element is in view
94+
* The callback called by the useOnInView hook once the element is in view
9095
*
9196
* Allows to return a cleanup function that will be called when the element goes out of view or when the observer is destroyed
9297
*/
93-
export type InViewEnterHookListener<TElement extends Element> = (
94-
/** Entry is always defined except when `initialInView` is true for the first call */
95-
entry: (IntersectionObserverEntry & { target: TElement }) | undefined,
98+
export type IntersectionChangeEffect<TElement extends Element> = (
99+
entry: IntersectionObserverEntry & { target: TElement },
96100
) => // biome-ignore lint/suspicious/noConfusingVoidType: Allow no return statement
97101
| void
98102
| undefined
103+
/** Entry is defined except when the element is unmounting */
99104
| ((entry?: IntersectionObserverEntry | undefined) => void);

src/useInView.tsx

+14-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22
import type { InViewHookResponse, IntersectionOptions } from "./index";
3-
import { useOnInViewChanged } from "./useOnInViewChanged";
3+
import { useOnInView } from "./useOnInView";
44

55
type State = {
66
inView: boolean;
@@ -38,10 +38,10 @@ export function useInView(
3838
): InViewHookResponse {
3939
const {
4040
threshold,
41-
delay,
42-
trackVisibility,
43-
rootMargin,
4441
root,
42+
rootMargin,
43+
trackVisibility,
44+
delay,
4545
triggerOnce,
4646
skip,
4747
initialInView,
@@ -58,17 +58,18 @@ export function useInView(
5858
const latestOptions = React.useRef(options);
5959
latestOptions.current = options;
6060

61-
// Create the ref tracking function using useOnInViewChanged
62-
const refCallback = useOnInViewChanged(
61+
// Create the ref tracking function using useOnInView
62+
const refCallback = useOnInView(
6363
// Combined callback - updates state, calls onChange, and returns cleanup if needed
6464
(entry) => {
65-
setState({ inView: true, entry });
65+
const inView = !initialInView;
66+
setState({ inView, entry });
6667

6768
const { onChange } = latestOptions.current;
6869
// Call the external onChange if provided
6970
// entry is undefined only if this is triggered by initialInView
70-
if (onChange && entry) {
71-
onChange(true, entry);
71+
if (onChange) {
72+
onChange(inView, entry);
7273
}
7374

7475
return triggerOnce
@@ -81,28 +82,28 @@ export function useInView(
8182
// Call the external onChange if provided
8283
// entry is undefined if the element is getting unmounted
8384
if (onChange && entry) {
84-
onChange(false, entry);
85+
onChange(!inView, entry);
8586
}
8687
// should not reset current state if changing skip
8788
if (!skip) {
8889
setState({
89-
inView: false,
90+
inView: !inView,
9091
entry: undefined,
9192
});
9293
}
9394
};
9495
},
9596
{
97+
threshold,
9698
root,
9799
rootMargin,
98-
threshold,
99100
// @ts-ignore
100101
trackVisibility,
101102
// @ts-ignore
102103
delay,
103-
initialInView,
104104
triggerOnce,
105105
skip,
106+
trigger: initialInView ? "leave" : undefined,
106107
},
107108
);
108109

0 commit comments

Comments
 (0)