Skip to content

Commit b181463

Browse files
committed
feat: adjust onChange so it only trigger after changes
1 parent d6bf2e7 commit b181463

File tree

8 files changed

+71
-10
lines changed

8 files changed

+71
-10
lines changed

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ 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+
7476
### `useOnInView` hook
7577

7678
```js
@@ -103,6 +105,8 @@ Key differences from `useInView`:
103105
- **Similar options** - Accepts all the same [options](#options) as `useInView`
104106
except `onChange`, `initialInView`, and `fallbackInView`
105107

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+
106110
```jsx
107111
import React from "react";
108112
import { useOnInView } from "react-intersection-observer";
@@ -149,18 +153,20 @@ state.
149153
```jsx
150154
import { InView } from "react-intersection-observer";
151155

152-
const Component = () => (
153-
<InView>
154-
{({ inView, ref, entry }) => (
156+
const Component = () => (
157+
<InView>
158+
{({ inView, ref, entry }) => (
155159
<div ref={ref}>
156160
<h2>{`Header inside viewport ${inView}.`}</h2>
157161
</div>
158162
)}
159163
</InView>
160164
);
161165

162-
export default Component;
163-
```
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.
164170
165171
### Plain children
166172

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: 4 additions & 1 deletion
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,
@@ -191,7 +194,7 @@ const SwitchHookComponent = ({
191194
/>
192195
<div
193196
data-testid="item-2"
194-
data-inview={!!toggle && inView}
197+
data-inview={toggle && inView}
195198
ref={toggle && !unmount ? ref : undefined}
196199
/>
197200
</>

src/__tests__/useOnInView.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ test("should call the callback when element comes into view", () => {
153153
expect(wrapper.getAttribute("data-call-count")).toBe("1");
154154
});
155155

156+
test("should ignore initial false intersection", () => {
157+
const { getByTestId } = render(<OnInViewChangedComponent />);
158+
const wrapper = getByTestId("wrapper");
159+
160+
mockAllIsIntersecting(false);
161+
expect(wrapper.getAttribute("data-call-count")).toBe("0");
162+
163+
mockAllIsIntersecting(true);
164+
expect(wrapper.getAttribute("data-call-count")).toBe("1");
165+
});
166+
156167
test("should call cleanup when element leaves view", () => {
157168
const { getByTestId } = render(<OnInViewChangedComponent />);
158169
mockAllIsIntersecting(true);

src/useInView.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function useInView({
4747
}: IntersectionOptions = {}): InViewHookResponse {
4848
const [ref, setRef] = React.useState<Element | null>(null);
4949
const callback = React.useRef<IntersectionOptions["onChange"]>(onChange);
50+
const lastInViewRef = React.useRef<boolean | undefined>(initialInView);
5051
const [state, setState] = React.useState<State>({
5152
inView: !!initialInView,
5253
entry: undefined,
@@ -59,13 +60,24 @@ export function useInView({
5960
// biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency
6061
React.useEffect(
6162
() => {
63+
if (lastInViewRef.current === undefined) {
64+
lastInViewRef.current = initialInView;
65+
}
6266
// Ensure we have node ref, and that we shouldn't skip observing
6367
if (skip || !ref) return;
6468

6569
let unobserve: (() => void) | undefined;
6670
unobserve = observe(
6771
ref,
6872
(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+
}
80+
6981
setState({
7082
inView,
7183
entry,
@@ -127,6 +139,7 @@ export function useInView({
127139
inView: !!initialInView,
128140
entry: undefined,
129141
});
142+
lastInViewRef.current = initialInView;
130143
}
131144

132145
const result = [setRef, state.inView, state.entry] as InViewHookResponse;

src/useOnInView.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const useOnInView = <TElement extends Element>(
6060
const onIntersectionChangeRef = React.useRef(onIntersectionChange);
6161
const observedElementRef = React.useRef<TElement | null>(null);
6262
const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined);
63+
const lastInViewRef = React.useRef<boolean | undefined>(undefined);
6364

6465
useSyncEffect(() => {
6566
onIntersectionChangeRef.current = onIntersectionChange;
@@ -85,6 +86,7 @@ export const useOnInView = <TElement extends Element>(
8586
if (!element || skip) {
8687
cleanupExisting();
8788
observedElementRef.current = null;
89+
lastInViewRef.current = undefined;
8890
return;
8991
}
9092

@@ -96,6 +98,14 @@ export const useOnInView = <TElement extends Element>(
9698
const destroyObserver = observe(
9799
element,
98100
(inView, entry) => {
101+
const previousInView = lastInViewRef.current;
102+
lastInViewRef.current = inView;
103+
104+
// Ignore the very first `false` notification so consumers only hear about actual state changes.
105+
if (previousInView === undefined && !inView) {
106+
return;
107+
}
108+
99109
onIntersectionChangeRef.current(
100110
inView,
101111
entry as IntersectionObserverEntry & { target: TElement },
@@ -121,6 +131,7 @@ export const useOnInView = <TElement extends Element>(
121131
destroyObserver();
122132
observedElementRef.current = null;
123133
observerCleanupRef.current = undefined;
134+
lastInViewRef.current = undefined;
124135
}
125136

126137
observerCleanupRef.current = stopObserving;
File renamed without changes.

0 commit comments

Comments
 (0)