Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Improve useIntersectionObserver #464

Merged
merged 4 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-pianos-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'usehooks-ts': minor
---

Updated `useIntersectionObserver` API and fixed #395, #271 and #182, see #464.
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useRef } from 'react'

import { useIntersectionObserver } from './useIntersectionObserver'

const Section = (props: { title: string }) => {
const ref = useRef<HTMLDivElement | null>(null)
const entry = useIntersectionObserver(ref, {})
const isVisible = !!entry?.isIntersecting
const { isIntersecting, ref } = useIntersectionObserver({
threshold: 0.5,
})

console.log(`Render Section ${props.title}`, { isVisible })
console.log(`Render Section ${props.title}`, {
isIntersecting,
})

return (
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
This React Hook detects visibility of a component on the viewport using the [`IntersectionObserver` API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) natively present in the browser.

It can be very useful to lazy-loading of images, implementing "infinite scrolling" or starting animations for example.
It can be very useful to lazy-loading of images, implementing "infinite scrolling", tracking view in GA or starting animations for example.

Your must pass the ref element (from `useRef()`).
### Option properties

It takes optionally `root`, `rootMargin` and `threshold` arguments from the [native `IntersectionObserver` API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and `freezeOnceVisible` to only catch the first appearance too.
- `threshold` (optional, default: `0`): A threshold indicating the percentage of the target's visibility needed to trigger the callback. Can be a single number or an array of numbers.
- `root` (optional, default: `null`): The element that is used as the viewport for checking visibility of the target. It can be an Element, Document, or null.
- `rootMargin` (optional, default: `'0%'`): A margin around the root. It specifies the size of the root's margin area.
- `freezeOnceVisible` (optional, default: `false`): If true, freezes the intersection state once the element becomes visible. Once the element enters the viewport and triggers the callback, further changes in intersection will not update the state.
- `onChange` (optional): A callback function to be invoked when the intersection state changes. It receives two parameters: `isIntersecting` (a boolean indicating if the element is intersecting) and `entry` (an IntersectionObserverEntry object representing the state of the intersection).
- `initialIsIntersecting` (optional, default: `false`): The initial state of the intersection. If set to true, indicates that the element is intersecting initially.

It returns the full IntersectionObserver's `entry` object.
**Note:** This interface extends the native `IntersectionObserverInit` interface, which provides the base options for configuring the Intersection Observer.

<br />
For more information on the Intersection Observer API and its options, refer to the [MDN Intersection Observer API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).

**Source:**
### Return

I discovered this way of using `IntersectionObserver` via this [post medium](https://medium.com/the-non-traditional-developer/how-to-use-an-intersectionobserver-in-a-react-hook-9fb061ac6cb5) while playing to build a [lazy-loaded collection of images](https://react-gallery.juliencaron.com/).
The `IntersectionResult` type supports both array and object destructuring and includes the following properties:

- `ref`: A function that can be used as a ref callback to set the target element.
- `isIntersecting`: A boolean indicating if the target element is intersecting with the viewport.
- `entry`: An optional `IntersectionObserverEntry` object representing the state of the intersection.
Original file line number Diff line number Diff line change
@@ -1,23 +1,65 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'

import type { RefObject } from 'react'

type State = {
isIntersecting: boolean
entry?: IntersectionObserverEntry
}

type ObserverCallback = (
isIntersecting: boolean,
entry: IntersectionObserverEntry,
) => void

/**
* Represents the options for configuring the Intersection Observer.
* @interface Args
* @property {number} [threshold=0] - A threshold indicating the percentage of the target's visibility needed to trigger the callback.
* @property {Element | null} [root=null] - The element that is used as the viewport for checking visibility of the target.
* @interface IntersectionObserverOptions
* @property {number | number[]} [threshold=0] - A threshold indicating the percentage of the target's visibility needed to trigger the callback.
* @property {Element | Document | null} [root=null] - The element that is used as the viewport for checking visibility of the target.
* @property {string} [rootMargin='0%'] - A margin around the root.
* @property {boolean} [freezeOnceVisible=false] - If true, freezes the intersection state once the element becomes visible.
* @property {ObserverCallback} [onChange] - A callback function to be invoked when the intersection state changes.
* @property {boolean} [initialIsIntersecting=false] - The initial state of the intersection.
*/
interface Args extends IntersectionObserverInit {
interface IntersectionObserverOptions extends IntersectionObserverInit {
freezeOnceVisible?: boolean
onChange?: ObserverCallback
initialIsIntersecting?: boolean
}

/** Supports both array and object destructing */
type IntersectionResult = [
(node?: Element | null) => void,
boolean,
IntersectionObserverEntry | undefined,
] & {
ref: (node?: Element | null) => void
isIntersecting: boolean
entry?: IntersectionObserverEntry
}

/**
* Custom hook for tracking the intersection of a DOM element with its containing element or the viewport.
* @param {IntersectionObserverOptions} options - The options for the Intersection Observer.
* @returns {IntersectionResult} The ref callback, a boolean indicating if the element is intersecting, and the intersection observer entry.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-intersection-observer)
* @see [MDN Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
* @example
* // Example 1
* const [ref, isIntersecting, entry] = useIntersectionObserver({ threshold: 0.5 });
*
* // Example 2
* const { ref, isIntersecting, entry } = useIntersectionObserver({ threshold: 0.5 });
*/
export function useIntersectionObserver(
options: IntersectionObserverOptions,
): IntersectionResult
/**
* @deprecated Use the new signature with an unique option object instead.
* Custom hook for tracking the intersection of a DOM element with its containing element or the viewport.
* @param {RefObject<Element>} elementRef - The ref object for the DOM element to observe.
* @param {Args} options - The options for the Intersection Observer (optional).
* @param {IntersectionObserverOptions} [options] - The options for the Intersection Observer (optional).
* @returns {IntersectionObserverEntry | undefined} The intersection observer entry representing the state of the intersection.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-intersection-observer)
* @see [MDN Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
Expand All @@ -29,39 +71,136 @@ interface Args extends IntersectionObserverInit {
*/
export function useIntersectionObserver(
elementRef: RefObject<Element>,
{
legacyOptions: IntersectionObserverOptions,
): IntersectionObserverEntry | undefined
/**
* Custom hook for tracking the intersection of a DOM element with its containing element or the viewport.
* @param {IntersectionObserverOptions | RefObject<Element>} optionsOrLegacyRef - The options for the Intersection Observer.
* @param {?IntersectionObserverOptions} [legacyOptions] - The options for the Intersection Observer (optional, legacy).
* @returns {NewIntersectionResult | IntersectionObserverEntry | undefined} The ref callback, a boolean indicating if the element is intersecting, and the intersection observer entry.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-intersection-observer)
* @see [MDN Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
* @example
* // Example 1
* const [ref, isIntersecting, entry] = useIntersectionObserver({ threshold: 0.5 });
*
* // Example 2
* const { ref, isIntersecting, entry } = useIntersectionObserver({ threshold: 0.5 });
*/
export function useIntersectionObserver(
optionsOrLegacyRef: IntersectionObserverOptions | RefObject<Element>,
legacyOptions?: IntersectionObserverOptions,
): IntersectionResult | IntersectionObserverEntry | undefined {
// TODO: Remove this mess when the old signature is removed.
const isLegacySignature = 'current' in optionsOrLegacyRef
const options = isLegacySignature ? legacyOptions : optionsOrLegacyRef
const {
threshold = 0,
root = null,
rootMargin = '0%',
freezeOnceVisible = false,
}: Args,
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>()
initialIsIntersecting = false,
} = options ?? {}

const frozen = entry?.isIntersecting && freezeOnceVisible
const [newRef, setNewRef] = useState<Element | null>(null)
const ref = isLegacySignature ? optionsOrLegacyRef.current : newRef

const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry)
}
const [state, setState] = useState<State>(() => ({
isIntersecting: initialIsIntersecting,
entry: undefined,
}))

const callbackRef = useRef<ObserverCallback>()

callbackRef.current = options?.onChange

const frozen = state.entry?.isIntersecting && freezeOnceVisible

useEffect(() => {
const node = elementRef.current // DOM Ref
if (!node) return
// Ensure we have a ref to observe
if (!ref) return

// Ensure the browser supports the Intersection Observer API
if (!('IntersectionObserver' in window)) return

// Skip if frozen
if (frozen) return

let unobserve: (() => void) | undefined

const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
const thresholds = Array.isArray(observer.thresholds)
? observer.thresholds
: [observer.thresholds]

entries.forEach(entry => {
const isIntersecting =
entry.isIntersecting &&
thresholds.some(threshold => entry.intersectionRatio >= threshold)

setState({ isIntersecting, entry })

const hasIOSupport = !!window.IntersectionObserver
if (!hasIOSupport || frozen) return
if (callbackRef.current) {
callbackRef.current(isIntersecting, entry)
}

const observerParams = { threshold, root, rootMargin }
const observer = new IntersectionObserver(updateEntry, observerParams)
if (isIntersecting && freezeOnceVisible && unobserve) {
unobserve()
unobserve = undefined
}
})
},
{ threshold, root, rootMargin },
)

observer.observe(node)
observer.observe(ref)

return () => {
observer.disconnect()
}

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, JSON.stringify(threshold), root, rootMargin, frozen])
}, [
ref,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(threshold),
root,
rootMargin,
frozen,
freezeOnceVisible,
])

// ensures that if the observed element changes, the intersection observer is reinitialized
const prevRef = useRef<Element | null>(null)

useEffect(() => {
if (
!ref &&
state.entry?.target &&
!freezeOnceVisible &&
!frozen &&
prevRef.current !== state.entry.target
) {
prevRef.current = state.entry.target
setState({ isIntersecting: initialIsIntersecting, entry: undefined })
}
}, [ref, state.entry, freezeOnceVisible, frozen, initialIsIntersecting])

if (isLegacySignature) {
return state.entry
}

const result = [
setNewRef,
!!state.isIntersecting,
state.entry,
] as IntersectionResult

// Support object destructuring, by adding the specific values.
result.ref = result[0]
result.isIntersecting = result[1]
result.entry = result[2]

return entry
return result
}
Loading