Skip to content

Commit 94d9844

Browse files
committed
feat: refactor useInView to utilize useOnInView and simplify options handling
1 parent c840320 commit 94d9844

6 files changed

Lines changed: 70 additions & 110 deletions

File tree

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,7 @@ Provide these as the options argument in the `useInView` hook or as props on the
215215
| **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. |
216216
| **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()` |
217217

218-
`useOnInView` accepts the same options as `useInView` except `onChange`,
219-
`initialInView`, and `fallbackInView`.
218+
`useOnInView` accepts the same options as `useInView` except `onChange`.
220219

221220
### InView Props
222221

src/__tests__/useOnInView.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { render } from "@testing-library/react";
2-
import { useCallback, useEffect, useState } from "react";
2+
import * as React from "react";
33
import type { IntersectionEffectOptions } from "..";
4+
import { supportsRefCleanup } from "../reactVersion";
45
import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils";
56
import { useOnInView } from "../useOnInView";
67

8+
const { useCallback, useEffect, useState } = React;
79
const OnInViewChangedComponent = ({
810
options,
911
unmount,
@@ -309,6 +311,9 @@ const MultipleCallbacksComponent = ({
309311
const mergedRefs = useCallback(
310312
(node: Element | null) => {
311313
const cleanup = [ref1(node), ref2(node), ref3(node)];
314+
if (!supportsRefCleanup) {
315+
return;
316+
}
312317
return () =>
313318
cleanup.forEach((fn) => {
314319
fn?.();

src/index.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,4 @@ export type InViewHookResponse = [
9090
entry?: IntersectionObserverEntry;
9191
};
9292

93-
export type IntersectionEffectOptions = Omit<
94-
IntersectionOptions,
95-
"onChange" | "fallbackInView" | "initialInView"
96-
>;
93+
export type IntersectionEffectOptions = IntersectionOptions;

src/reactVersion.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import * as React from "react";
2+
3+
const major = Number.parseInt(React.version?.split(".")[0] ?? "", 10);
4+
// NaN => unknown version; default to false to avoid returning ref cleanup on <19.
5+
export const supportsRefCleanup = Number.isFinite(major) && major >= 19;

src/useInView.tsx

Lines changed: 43 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from "react";
22
import type { IntersectionOptions, InViewHookResponse } from "./index";
3-
import { observe } from "./observe";
3+
import { supportsRefCleanup } from "./reactVersion";
4+
import { useOnInView } from "./useOnInView";
45

56
type State = {
67
inView: boolean;
@@ -33,116 +34,61 @@ type State = {
3334
* };
3435
* ```
3536
*/
36-
export function useInView({
37-
threshold,
38-
delay,
39-
trackVisibility,
40-
rootMargin,
41-
root,
42-
triggerOnce,
43-
skip,
44-
initialInView,
45-
fallbackInView,
46-
onChange,
47-
}: IntersectionOptions = {}): InViewHookResponse {
48-
const [ref, setRef] = React.useState<Element | null>(null);
49-
const callback = React.useRef<IntersectionOptions["onChange"]>(onChange);
50-
const lastInViewRef = React.useRef<boolean | undefined>(initialInView);
37+
export function useInView(
38+
options: IntersectionOptions = {},
39+
): InViewHookResponse {
5140
const [state, setState] = React.useState<State>({
52-
inView: !!initialInView,
41+
inView: !!options.initialInView,
5342
entry: undefined,
5443
});
44+
const optionsRef = React.useRef(options);
45+
optionsRef.current = options;
46+
const entryTargetRef = React.useRef<Element | undefined>(undefined);
5547

56-
// Store the onChange callback in a `ref`, so we can access the latest instance
57-
// inside the `useEffect`, but without triggering a rerender.
58-
callback.current = onChange;
59-
60-
// biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency
61-
React.useEffect(
62-
() => {
63-
if (lastInViewRef.current === undefined) {
64-
lastInViewRef.current = initialInView;
65-
}
66-
// Ensure we have node ref, and that we shouldn't skip observing
67-
if (skip || !ref) return;
68-
69-
let unobserve: (() => void) | undefined;
70-
unobserve = observe(
71-
ref,
72-
(inView, entry) => {
73-
const previousInView = lastInViewRef.current;
74-
lastInViewRef.current = inView;
75-
76-
// Ignore the very first `false` notification so consumers only hear about actual state changes.
77-
if (previousInView === undefined && !inView) {
78-
return;
79-
}
48+
const inViewRef = useOnInView((inView, entry) => {
49+
entryTargetRef.current = entry.target;
50+
setState({ inView, entry });
51+
if (optionsRef.current.onChange) {
52+
optionsRef.current.onChange(inView, entry);
53+
}
54+
}, options);
8055

56+
const refCallback = React.useCallback(
57+
(node: Element | null) => {
58+
const resetIfNeeded = () => {
59+
const {
60+
skip,
61+
triggerOnce,
62+
initialInView: latestInitialInView,
63+
} = optionsRef.current;
64+
if (!skip && !triggerOnce && entryTargetRef.current) {
8165
setState({
82-
inView,
83-
entry,
66+
inView: !!latestInitialInView,
67+
entry: undefined,
8468
});
85-
if (callback.current) callback.current(inView, entry);
69+
entryTargetRef.current = undefined;
70+
}
71+
};
72+
const cleanup = inViewRef(node);
8673

87-
if (entry.isIntersecting && triggerOnce && unobserve) {
88-
// If it should only trigger once, unobserve the element after it's inView
89-
unobserve();
90-
unobserve = undefined;
91-
}
92-
},
93-
{
94-
root,
95-
rootMargin,
96-
threshold,
97-
// @ts-expect-error
98-
trackVisibility,
99-
delay,
100-
},
101-
fallbackInView,
102-
);
74+
if (!node) {
75+
resetIfNeeded();
76+
return;
77+
}
78+
79+
if (!supportsRefCleanup) {
80+
return;
81+
}
10382

10483
return () => {
105-
if (unobserve) {
106-
unobserve();
107-
}
84+
cleanup?.();
85+
resetIfNeeded();
10886
};
10987
},
110-
// We break the rule here, because we aren't including the actual `threshold` variable
111-
// eslint-disable-next-line react-hooks/exhaustive-deps
112-
[
113-
// If the threshold is an array, convert it to a string, so it won't change between renders.
114-
Array.isArray(threshold) ? threshold.toString() : threshold,
115-
ref,
116-
root,
117-
rootMargin,
118-
triggerOnce,
119-
skip,
120-
trackVisibility,
121-
fallbackInView,
122-
delay,
123-
],
88+
[inViewRef],
12489
);
12590

126-
const entryTarget = state.entry?.target;
127-
const previousEntryTarget = React.useRef<Element | undefined>(undefined);
128-
if (
129-
!ref &&
130-
entryTarget &&
131-
!triggerOnce &&
132-
!skip &&
133-
previousEntryTarget.current !== entryTarget
134-
) {
135-
// If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`)
136-
// This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView
137-
previousEntryTarget.current = entryTarget;
138-
setState({
139-
inView: !!initialInView,
140-
entry: undefined,
141-
});
142-
lastInViewRef.current = initialInView;
143-
}
144-
145-
const result = [setRef, state.inView, state.entry] as InViewHookResponse;
91+
const result = [refCallback, state.inView, state.entry] as InViewHookResponse;
14692

14793
// Support object destructuring, by adding the specific values.
14894
result.ref = result[0];

src/useOnInView.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
IntersectionEffectOptions,
55
} from "./index";
66
import { observe } from "./observe";
7+
import { supportsRefCleanup } from "./reactVersion";
78

89
const useSyncEffect =
910
(
@@ -55,18 +56,21 @@ export const useOnInView = <TElement extends Element>(
5556
delay,
5657
triggerOnce,
5758
skip,
59+
initialInView,
60+
fallbackInView,
5861
}: IntersectionEffectOptions = {},
5962
) => {
6063
const onIntersectionChangeRef = React.useRef(onIntersectionChange);
64+
const initialInViewValue = initialInView ? true : undefined;
6165
const observedElementRef = React.useRef<TElement | null>(null);
6266
const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined);
63-
const lastInViewRef = React.useRef<boolean | undefined>(undefined);
67+
const lastInViewRef = React.useRef<boolean | undefined>(initialInViewValue);
6468

6569
useSyncEffect(() => {
6670
onIntersectionChangeRef.current = onIntersectionChange;
6771
}, [onIntersectionChange]);
6872

69-
// biome-ignore lint/correctness/useExhaustiveDependencies: Threshold arrays are normalized inside the callback
73+
// biome-ignore lint/correctness/useExhaustiveDependencies: threshold array handled inside
7074
return React.useCallback(
7175
(element: TElement | undefined | null) => {
7276
// React <19 never calls ref callbacks with `null` during unmount, so we
@@ -80,19 +84,20 @@ export const useOnInView = <TElement extends Element>(
8084
};
8185

8286
if (element === observedElementRef.current) {
83-
return observerCleanupRef.current;
87+
return supportsRefCleanup ? observerCleanupRef.current : undefined;
8488
}
8589

8690
if (!element || skip) {
8791
cleanupExisting();
8892
observedElementRef.current = null;
89-
lastInViewRef.current = undefined;
90-
return;
93+
lastInViewRef.current = initialInViewValue;
94+
return undefined;
9195
}
9296

9397
cleanupExisting();
9498

9599
observedElementRef.current = element;
100+
lastInViewRef.current = initialInViewValue;
96101
let destroyed = false;
97102

98103
const destroyObserver = observe(
@@ -121,6 +126,7 @@ export const useOnInView = <TElement extends Element>(
121126
trackVisibility,
122127
delay,
123128
} as IntersectionObserverInit,
129+
fallbackInView,
124130
);
125131

126132
function stopObserving() {
@@ -136,7 +142,7 @@ export const useOnInView = <TElement extends Element>(
136142

137143
observerCleanupRef.current = stopObserving;
138144

139-
return observerCleanupRef.current;
145+
return supportsRefCleanup ? observerCleanupRef.current : undefined;
140146
},
141147
[
142148
Array.isArray(threshold) ? threshold.toString() : threshold,
@@ -146,6 +152,8 @@ export const useOnInView = <TElement extends Element>(
146152
delay,
147153
triggerOnce,
148154
skip,
155+
initialInViewValue,
156+
fallbackInView,
149157
],
150158
);
151159
};

0 commit comments

Comments
 (0)