From 0c1885fb4456b0989c792d3ef363071121692de2 Mon Sep 17 00:00:00 2001 From: TT-Wang Date: Fri, 11 Sep 2026 19:04:27 +0800 Subject: [PATCH] feat(useMeasure): support tracking an element via options.ref Allow passing an existing ref through the new optional `options.ref` parameter. When provided, the hook observes `ref.current` instead of the element assigned to the callback ref it returns. This makes it possible to measure an element that already has a ref, and to use multiple useMeasure hooks in one component to track different elements. Closes #1227 --- docs/useMeasure.md | 33 +++++++++++++---- src/useMeasure.ts | 22 +++++++++--- tests/useMeasure.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/docs/useMeasure.md b/docs/useMeasure.md index 306fec3b6a..5b70979e6b 100644 --- a/docs/useMeasure.md +++ b/docs/useMeasure.md @@ -12,7 +12,7 @@ const Demo = () => { return (
-
x: {x}
+
x: {x}
y: {y}
width: {width}
height: {height}
@@ -25,21 +25,40 @@ const Demo = () => { }; ``` -This hook uses [`ResizeObserver` API][resize-observer], if you want to support -legacy browsers, consider installing [`resize-observer-polyfill`][resize-observer-polyfill] -before running your app. +You can also track an element you already hold a ref to, by passing it via the +`ref` option — the hook will observe `ref.current` instead of the element +assigned to the callback ref it returns. This also allows using multiple +`useMeasure` hooks in the same component to track different elements. + +```jsx +import { useMeasure } from "react-use"; + +const Demo = () => { + const myRef = useRef(null); + const { width, height } = useMeasure({ ref: myRef })[1]; + + return ( +
+
width: {width}
+
height: {height}
+
+ ); +}; +``` + +This hook uses [`ResizeObserver` API][resize-observer], if you want to support +legacy browsers, consider installing [`resize-observer-polyfill`][resize-observer-polyfill] +before running your app. ```js if (!window.ResizeObserver) { - window.ResizeObserver = (await import('resize-observer-polyfill')).default; + window.ResizeObserver = (await import("resize-observer-polyfill")).default; } ``` - ## Related hooks - [useSize](./useSize.md) - [resize-observer]: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver [resize-observer-polyfill]: https://www.npmjs.com/package/resize-observer-polyfill diff --git a/src/useMeasure.ts b/src/useMeasure.ts index e14f217f1c..44dd74ad31 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { RefObject, useMemo, useState } from 'react'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; import { isBrowser, noop } from './misc/util'; @@ -8,6 +8,14 @@ export type UseMeasureRect = Pick< >; export type UseMeasureRef = (element: E) => void; export type UseMeasureResult = [UseMeasureRef, UseMeasureRect]; +export interface UseMeasureOptions { + /** + * An optional external ref of the element to measure. When provided, the + * hook observes `ref.current` instead of the element assigned to the + * callback ref it returns. + */ + ref?: RefObject; +} const defaultState: UseMeasureRect = { x: 0, @@ -20,9 +28,12 @@ const defaultState: UseMeasureRect = { right: 0, }; -function useMeasure(): UseMeasureResult { +function useMeasure( + options?: UseMeasureOptions +): UseMeasureResult { const [element, ref] = useState(null); const [rect, setRect] = useState(defaultState); + const externalRef = options ? options.ref : undefined; const observer = useMemo( () => @@ -36,12 +47,13 @@ function useMeasure(): UseMeasureResult { ); useIsomorphicLayoutEffect(() => { - if (!element) return; - observer.observe(element); + const target = externalRef ? externalRef.current : element; + if (!target) return; + observer.observe(target); return () => { observer.disconnect(); }; - }, [element]); + }, [element, externalRef, externalRef && externalRef.current]); return [ref, rect]; } diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts index c32fdaeee7..1acee26a6a 100644 --- a/tests/useMeasure.test.ts +++ b/tests/useMeasure.test.ts @@ -181,3 +181,79 @@ it('calls .disconnect() on ResizeObserver when component unmounts', () => { expect(disconnect).toHaveBeenCalledTimes(1); }); + +it('tracks rectangle of an element passed via options.ref', () => { + let listener: ((rect: any) => void) | undefined = undefined; + const observed: Element[] = []; + (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; + } + observe(el: Element) { + observed.push(el); + } + disconnect() {} + }; + + const div = document.createElement('div'); + const externalRef = { current: div as Element | null }; + + const { result } = renderHook(() => useMeasure({ ref: externalRef })); + + expect(observed).toContain(div); + expect(result.current[1]).toMatchObject({ width: 0, height: 0 }); + + act(() => { + listener!([ + { + contentRect: { + x: 3, + y: 4, + width: 300, + height: 150, + top: 30, + bottom: 40, + left: 50, + right: 60, + }, + }, + ]); + }); + + expect(result.current[1]).toMatchObject({ + x: 3, + y: 4, + width: 300, + height: 150, + top: 30, + bottom: 40, + left: 50, + right: 60, + }); +}); + +it('observes a new element when options.ref.current changes', () => { + const observed: Element[] = []; + (window as any).ResizeObserver = class ResizeObserver { + constructor() {} + observe(el: Element) { + observed.push(el); + } + disconnect() {} + }; + + const divA = document.createElement('div'); + const divB = document.createElement('div'); + const externalRef = { current: divA as Element | null }; + + const { rerender } = renderHook(({ ref }) => useMeasure({ ref }), { + initialProps: { ref: externalRef }, + }); + + expect(observed).toEqual([divA]); + + externalRef.current = divB; + rerender({ ref: externalRef }); + + expect(observed).toEqual([divA, divB]); +});