Skip to content
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
10 changes: 10 additions & 0 deletions packages/core/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,13 @@ function Component() {
## 6.1.9(Jan 2026)

- fix(useRafState): fix bug where multiple consecutive functional updates would only apply the last one. Now correctly accumulates all updates within the same animation frame, matching React's useState behavior. For example, calling `setState(n => n + 1)` three times consecutively will now correctly increase the value by 3 instead of 1.

## 6.1.11(Jan 20, 2026)

- fix(usePageLeave): fix infinite re-render issue caused by unstable handler references
- fix(useEventListener): improve stability to prevent unnecessary event listener re-bindings while maintaining support for ref-based targets. Uses stable element identifiers: for refs, tracks the ref object itself; for functions/direct elements, tracks the resolved actual element. This allows function-based targets like `() => document` to work without causing infinite loops, while still re-binding when the actual target element changes
- fix(useSticky): fix infinite re-render issue when passing unstable function references as target or scroll element
- fix(useMutationObserver): add missing `target` dependency - now correctly re-observes when target element changes
- fix(useResizeObserver): fix infinite re-render issue when passing unstable function references as target
- fix(useIntersectionObserver): add missing `target` dependency - now correctly re-observes when target element changes
- feat(useStableTarget): add new internal utility hook for creating stable identifiers for BasicTarget parameters that can be safely used in effect dependencies. This solves the common problem where passing unstable function references like `() => document` would cause infinite re-renders
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reactuses/core",
"version": "6.1.9",
"version": "6.1.11",
"license": "Unlicense",
"homepage": "https://www.reactuse.com/",
"repository": {
Expand Down
27 changes: 18 additions & 9 deletions packages/core/src/useEventListener/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,29 @@ import { defaultOptions } from '../utils/defaults'
import type { BasicTarget } from '../utils/domTarget'
import { getTargetElement } from '../utils/domTarget'
import { useDeepCompareEffect } from '../useDeepCompareEffect'
import { isBrowser } from '../utils/is'
import { useStableTarget } from '../utils/useStableTarget'

export type Target = BasicTarget<HTMLElement | Element | Window | Document | EventTarget>

// Overload 1 Window Event based useEventListener interface
export function useEventListener<K extends keyof WindowEventMap>(
function useEventListenerImpl<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: Window,
options?: boolean | AddEventListenerOptions
): void

// Overload 2 Document Event based useEventListener interface
export function useEventListener<K extends keyof DocumentEventMap>(
function useEventListenerImpl<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: Document,
options?: boolean | AddEventListenerOptions
): void

// Overload 3 HTMLElement Event based useEventListener interface
export function useEventListener<
function useEventListenerImpl<
K extends keyof HTMLElementEventMap,
T extends HTMLElement = HTMLDivElement,
>(
Expand All @@ -35,39 +37,42 @@ export function useEventListener<
): void

// Overload 4 Element Event based useEventListener interface
export function useEventListener<K extends keyof ElementEventMap>(
function useEventListenerImpl<K extends keyof ElementEventMap>(
eventName: K,
handler: (event: ElementEventMap[K]) => void,
element: Element,
options?: boolean | AddEventListenerOptions
): void

// Overload 5 Element Event based useEventListener interface
export function useEventListener<K = Event>(
function useEventListenerImpl<K = Event>(
eventName: string,
handler: (event: K) => void,
element: EventTarget | null | undefined,
options?: boolean | AddEventListenerOptions
): void

// Overload 6
export function useEventListener(
function useEventListenerImpl(
eventName: string,
handler: (...p: any) => void,
element?: Target,
options?: boolean | AddEventListenerOptions
): void

export function useEventListener(
function useEventListenerImpl(
eventName: string,
handler: (...p: any) => void,
element?: Target,
options: boolean | AddEventListenerOptions = defaultOptions,
) {
const savedHandler = useLatest(handler)
const { key: elementKey, ref: elementRef } = useStableTarget(element, defaultWindow)

useDeepCompareEffect(() => {
const targetElement = getTargetElement(element, defaultWindow)
// Call getTargetElement inside effect to support ref-based targets
// (ref.current is null during render, only available in commit phase)
const targetElement = getTargetElement(elementRef.current, defaultWindow)
if (!(targetElement && targetElement.addEventListener)) {
return
}
Expand All @@ -83,5 +88,9 @@ export function useEventListener(
}
off(targetElement, eventName, eventListener)
}
}, [eventName, element, options])
}, [eventName, elementKey, options])
}

function noop() {}

export const useEventListener = isBrowser ? useEventListenerImpl : noop as typeof useEventListenerImpl
6 changes: 4 additions & 2 deletions packages/core/src/useIntersectionObserver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLatest } from '../useLatest'
import { defaultOptions } from '../utils/defaults'
import { useDeepCompareEffect } from '../useDeepCompareEffect'
import { getTargetElement } from '../utils/domTarget'
import { useStableTarget } from '../utils/useStableTarget'
import type { UseIntersectionObserver } from './interface'

export const useIntersectionObserver: UseIntersectionObserver = (
Expand All @@ -12,6 +13,7 @@ export const useIntersectionObserver: UseIntersectionObserver = (
): () => void => {
const savedCallback = useLatest(callback)
const observerRef = useRef<IntersectionObserver>()
const { key: targetKey, ref: targetRef } = useStableTarget(target)

const stop = useCallback(() => {
if (observerRef.current) {
Expand All @@ -20,7 +22,7 @@ export const useIntersectionObserver: UseIntersectionObserver = (
}, [])

useDeepCompareEffect(() => {
const element = getTargetElement(target)
const element = getTargetElement(targetRef.current)
if (!element) {
return
}
Expand All @@ -32,7 +34,7 @@ export const useIntersectionObserver: UseIntersectionObserver = (
observerRef.current.observe(element)

return stop
}, [options])
}, [targetKey, options])

return stop
}
6 changes: 4 additions & 2 deletions packages/core/src/useMutationObserver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLatest } from '../useLatest'
import { defaultOptions } from '../utils/defaults'
import { useDeepCompareEffect } from '../useDeepCompareEffect'
import { type BasicTarget, getTargetElement } from '../utils/domTarget'
import { useStableTarget } from '../utils/useStableTarget'
import type { UseMutationObserver } from './interface'

export const useMutationObserver: UseMutationObserver = (
Expand All @@ -12,6 +13,7 @@ export const useMutationObserver: UseMutationObserver = (
): (() => void) => {
const callbackRef = useLatest(callback)
const observerRef = useRef<MutationObserver>()
const { key: targetKey, ref: targetRef } = useStableTarget(target)

const stop = useCallback(() => {
if (observerRef.current) {
Expand All @@ -20,15 +22,15 @@ export const useMutationObserver: UseMutationObserver = (
}, [])

useDeepCompareEffect(() => {
const element = getTargetElement(target)
const element = getTargetElement(targetRef.current)
if (!element) {
return
}
observerRef.current = new MutationObserver(callbackRef.current)

observerRef.current.observe(element, options)
return stop
}, [options])
}, [targetKey, options])

return stop
}
7 changes: 4 additions & 3 deletions packages/core/src/usePageLeave/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useEventListener } from '../useEventListener'
import { defaultDocument, defaultWindow } from '../utils/browser'

export function usePageLeave(): boolean {
const [isLeft, setIsLeft] = useState(false)
Expand All @@ -14,9 +15,9 @@ export function usePageLeave(): boolean {
setIsLeft(!from)
}

useEventListener('mouseout', handler, () => window, { passive: true })
useEventListener('mouseleave', handler, () => document, { passive: true })
useEventListener('mouseenter', handler, () => document, { passive: true })
useEventListener('mouseout', handler, defaultWindow, { passive: true })
useEventListener('mouseleave', handler, defaultDocument, { passive: true })
useEventListener('mouseenter', handler, defaultDocument, { passive: true })

return isLeft
}
7 changes: 5 additions & 2 deletions packages/core/src/useResizeObserver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLatest } from '../useLatest'
import { defaultOptions } from '../utils/defaults'
import { useDeepCompareEffect } from '../useDeepCompareEffect'
import { getTargetElement } from '../utils/domTarget'
import { useStableTarget } from '../utils/useStableTarget'
import type { UseResizeObserver } from './interface'

export const useResizeObserver: UseResizeObserver = (
Expand All @@ -12,22 +13,24 @@ export const useResizeObserver: UseResizeObserver = (
): () => void => {
const savedCallback = useLatest(callback)
const observerRef = useRef<ResizeObserver>()
const { key: targetKey, ref: targetRef } = useStableTarget(target)

const stop = useCallback(() => {
if (observerRef.current) {
observerRef.current.disconnect()
}
}, [])

useDeepCompareEffect(() => {
const element = getTargetElement(target)
const element = getTargetElement(targetRef.current)
if (!element) {
return
}
observerRef.current = new ResizeObserver(savedCallback.current)
observerRef.current.observe(element, options)

return stop
}, [savedCallback, stop, target, options])
}, [targetKey, options])

return stop
}
20 changes: 13 additions & 7 deletions packages/core/src/useSticky/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,35 @@
import { getTargetElement } from '../utils/domTarget'
import { useThrottleFn } from '../useThrottleFn'
import { getScrollParent } from '../utils/scroll'
import { useStableTarget } from '../utils/useStableTarget'
import { useLatest } from '../useLatest'
import type { UseStickyParams } from './interface'

export function useSticky(targetElement: BasicTarget<HTMLElement>, { axis = 'y', nav = 0 }: UseStickyParams, scrollElement?: BasicTarget<HTMLElement>): [boolean, React.Dispatch<React.SetStateAction<boolean>>] {
const [isSticky, setSticky] = useState<boolean>(false)
const { key: targetKey, ref: targetRef } = useStableTarget(targetElement)
const { key: scrollKey, ref: scrollRef } = useStableTarget(scrollElement)
const axisRef = useLatest(axis)
const navRef = useLatest(nav)

const { run: scrollHandler } = useThrottleFn(() => {
const element = getTargetElement(targetElement)
const element = getTargetElement(targetRef.current)
if (!element) {
return
}
const rect = element.getBoundingClientRect()
if (axis === 'y') {
setSticky(rect?.top <= nav)
if (axisRef.current === 'y') {
setSticky(rect?.top <= navRef.current)
}
else {
setSticky(rect?.left <= nav)
setSticky(rect?.left <= navRef.current)
}
}, 50)

useEffect(() => {
const element = getTargetElement(targetElement)
const element = getTargetElement(targetRef.current)
const scrollParent
= getTargetElement(scrollElement) || getScrollParent(axis, element)
= getTargetElement(scrollRef.current) || getScrollParent(axisRef.current, element)
if (!element || !scrollParent) {
return
}
Expand All @@ -36,6 +42,6 @@
return () => {
scrollParent.removeEventListener('scroll', scrollHandler)
}
}, [axis, targetElement, scrollElement, scrollHandler])
}, [targetKey, scrollKey, scrollHandler])

Check warning on line 45 in packages/core/src/useSticky/index.ts

View workflow job for this annotation

GitHub Actions / CI

React Hook useEffect has missing dependencies: 'axisRef', 'scrollRef', and 'targetRef'. Either include them or remove the dependency array
return [isSticky, setSticky]
}
57 changes: 57 additions & 0 deletions packages/core/src/utils/useStableTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useRef } from 'react'
import type { BasicTarget } from './domTarget'
import { getTargetElement } from './domTarget'

/**
* Creates a stable identifier for a BasicTarget that can be safely used in effect dependencies.
*
* This hook solves the problem where passing unstable function references like `() => document`
* would cause infinite re-renders when used directly in effect dependency arrays.
*
* @param target - The target element (ref, function, or direct element)
* @param defaultElement - Default element to use if target is undefined
* @returns A stable reference that only changes when the actual target element changes
*
* @example
* ```tsx
* // For ref objects: returns the ref itself (stable)
* const ref = useRef<HTMLDivElement>(null)
* const key = useStableTarget(ref) // key === ref (stable)
*
* // For functions: returns the resolved actual element
* const key = useStableTarget(() => document) // key === document (stable)
*
* // For direct elements: returns the element itself
* const key = useStableTarget(divElement) // key === divElement (stable)
* ```
*/
export function useStableTarget<T extends HTMLElement | Element | Window | Document | EventTarget>(
target?: BasicTarget<T>,
defaultElement?: T,
) {
const targetRef = useRef(target)
targetRef.current = target

// Calculate stable key without memoization
// For ref objects: return the ref itself (always stable)
// For functions/direct elements: resolve to the actual element
let stableKey: any
if (!target) {
stableKey = defaultElement ?? null
}
else if (typeof target === 'object' && 'current' in target) {
// Ref object - use the ref itself as the stable key
stableKey = target
}
else {
// Function or direct element - resolve to actual element
stableKey = getTargetElement(target, defaultElement)
}

return {
/** The stable key that can be safely used in effect dependencies */
key: stableKey,
/** A ref containing the current target (useful for accessing in effects) */
ref: targetRef,
}
}
10 changes: 10 additions & 0 deletions packages/website-docusaurus/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ description: >-
---
# ChangeLog

## 6.1.11(Jan 20, 2026)

- fix(usePageLeave): fix infinite re-render issue caused by unstable handler references
- fix(useEventListener): improve stability to prevent unnecessary event listener re-bindings while maintaining support for ref-based targets. Uses stable element identifiers: for refs, tracks the ref object itself; for functions/direct elements, tracks the resolved actual element. This allows function-based targets like `() => document` to work without causing infinite loops, while still re-binding when the actual target element changes
- fix(useSticky): fix infinite re-render issue when passing unstable function references as target or scroll element
- fix(useMutationObserver): add missing `target` dependency - now correctly re-observes when target element changes
- fix(useResizeObserver): fix infinite re-render issue when passing unstable function references as target
- fix(useIntersectionObserver): add missing `target` dependency - now correctly re-observes when target element changes
- feat(useStableTarget): add new internal utility hook for creating stable identifiers for BasicTarget parameters that can be safely used in effect dependencies. This solves the common problem where passing unstable function references like `() => document` would cause infinite re-renders

## 6.1.9(Jan 2026)

- fix(useRafState): fix bug where multiple consecutive functional updates would only apply the last one. Now correctly accumulates all updates within the same animation frame, matching React's useState behavior. For example, calling `setState(n => n + 1)` three times consecutively will now correctly increase the value by 3 instead of 1.
Expand Down
4 changes: 2 additions & 2 deletions packages/website-docusaurus/static/llm.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1006,8 +1006,8 @@ UnLicense - Use freely without restrictions

## Companies Using ReactUse

PDD (Pinduoduo) - E-Commerce | Ctrip - Travel Platform
PDD (Pinduoduo) - E-Commerce | Shopee - E-Commerce | Ctrip - Travel Platform | Bambu Lab - 3D Printing

---

Generated: 2026-01-13T15:19:40.594Z | Total Hooks: 112
Generated: 2026-01-20T09:42:26.595Z | Total Hooks: 112