Skip to content

Commit

Permalink
✨ Improve useIntersectionObserver (#464)
Browse files Browse the repository at this point in the history
* ✨ Improve useIntersectionObserver

* 🔖 Add changeset

* 🐛 Typo
  • Loading branch information
juliencrn committed Feb 5, 2024
1 parent 6ec6648 commit f39078f
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 36 deletions.
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
}

0 comments on commit f39078f

Please sign in to comment.