-
-
Save souporserious/b8f759ace04eea747382b88f0872300d to your computer and use it in GitHub Desktop.
// Handle outside and local measurements easily | |
function Singular({ ref, value }) { | |
const [width, setWidth] = useState(-1) | |
const [useMeasureRef, useMeasureEffect] = useMeasure() | |
useMeasureRef(ref) | |
useMeasureEffect( | |
([node]) => { | |
setWidth(node.offsetWidth) | |
}, | |
[value] | |
) | |
return <div>Outside measurement: {width}</div> | |
} | |
// Or pass useMeasureRef down to collect multiple measurements | |
function Parent({ children }) { | |
const [totalWidth, setTotalWidth] = useState(-1) | |
const [useMeasureRef, useMeasureEffect] = useMeasure() | |
useMeasureEffect(nodes => { | |
setTotalWidth( | |
nodes.reduce((total, node) => total + node.offsetWidth, 0) | |
) | |
}) | |
return ( | |
<div> | |
Total width: {totalWidth} | |
{Children.map(children, child => | |
React.cloneElement(child, { useMeasureRef }) | |
)} | |
</div> | |
) | |
} | |
function Child({ useMeasureRef, ...props }) { | |
const ref = useMeasureRef() | |
return <div ref={ref} {...props} /> | |
} |
import * as React from 'react' | |
import ResizeObserver from 'resize-observer-polyfill' | |
import mitt from 'mitt' | |
let animationId = null | |
const emitter = mitt() | |
const handleMeasure = () => { | |
if (animationId === null) { | |
animationId = window.requestAnimationFrame(() => { | |
emitter.emit('measure') | |
window.requestAnimationFrame(() => { | |
animationId = null | |
}) | |
}) | |
} | |
} | |
const mutationObserver = | |
typeof window === 'undefined' ? {} : new MutationObserver(handleMeasure) | |
const resizeObserver = | |
typeof window === 'undefined' ? {} : new ResizeObserver(handleMeasure) | |
/** | |
* Creates a set of hooks to help manage collecting and using measurements | |
* from one or many components. | |
* | |
* @example | |
* | |
* function Singular({ ref, value }) { | |
* const [useMeasureRef, useMeasureEffect] = useMeasure() | |
* useMeasureRef(ref) | |
* useMeasureEffect(([node]) => { | |
* console.log(node) | |
* }, [value]) | |
* return <div /> | |
* } | |
* | |
* function Parent({ children }) { | |
* const [useMeasureRef, useMeasureEffect] = useMeasure() | |
* useMeasureEffect(nodes => { | |
* console.log(nodes) | |
* }, []) | |
* return ( | |
* <div> | |
* {Children.map(children, child => | |
* React.cloneElement(child, { useMeasureRef }) | |
* )} | |
* </div> | |
* ) | |
* } | |
* | |
* function Child({ useMeasureRef }) { | |
* const ref = useMeasureRef(ref) | |
* return <div ref={ref} /> | |
* } | |
* | |
* @returns {[(ref?: React.RefObject) => React.RefObject, (onMeasure: Function, dependencies: Array) => void]} | |
*/ | |
export function useMeasure() { | |
const refs = React.useRef([]) | |
const storeRef = React.useCallback(node => { | |
if (refs.current.indexOf(node) === -1) { | |
refs.current.push(node) | |
} | |
}, []) | |
const refsEmitter = React.useMemo(() => { | |
const emitter = mitt() | |
emitter.on('storeRef', storeRef) | |
return emitter | |
}) | |
const useMeasureRef = React.useCallback(ref => { | |
const measureRef = ref || React.useRef() | |
React.useLayoutEffect(() => { | |
const node = measureRef.current | |
mutationObserver.observe(node, { | |
characterData: true, | |
childList: true, | |
subtree: true, | |
}) | |
resizeObserver.observe(node) | |
return () => { | |
resizeObserver.unobserve(node) | |
} | |
}, []) | |
React.useLayoutEffect(() => { | |
refsEmitter.emit('storeRef', measureRef.current) | |
}) | |
return measureRef | |
}, []) | |
const useMeasureEffect = React.useCallback((onMeasure, dependencies) => { | |
React.useLayoutEffect(() => { | |
function handleMeasure() { | |
onMeasure([...refs.current]) | |
} | |
handleMeasure() | |
emitter.on('measure', handleMeasure) | |
return () => { | |
emitter.off('measure', handleMeasure) | |
} | |
}, dependencies) | |
React.useLayoutEffect(() => { | |
return () => { | |
refs.current = [] | |
} | |
}) | |
React.useEffect(() => { | |
return () => { | |
refsEmitter.off('storeRef', storeRef) | |
} | |
}, []) | |
}, []) | |
return [useMeasureRef, useMeasureEffect] | |
} |
Thank you for the detailed reply @Andarist!
I need to explain this better, and will do so in an RFC on react-measure, but the main reason for the particular API is to accommodate collecting child/multiple measurements in the proper order based off the position in the React tree. So less focus on collecting singular measurements, although the current API allows to do this as well.
is there any particular reason why this doesnt have any deps?
Yes, this works similar to how Downshift stores/clears items. This is what makes useMeasureRef
necessary. It's responsible for collecting measurements in the order they render in the tree. I'd like to get more feedback on this approach to see if there are any quirks that I'm not aware of yet, but in some testing of this pattern it seems to work well in multiple situations, including Suspense.
I would also suggest keeping additional refs inside a map with named keys (instead of inside an array).
I agree, named collections are useful especially when working with multiple measurements. Maybe useMeasureRef
takes an optional id
or something to identify it, then we return an array of tuples [id, node]
in useMeasureEffect
?
I believe the better way is to always require a ref to be provided by a user.
Agree here as well, I was trying to simplify some use cases by returning a ref as well, but just requiring React.useRef
I think is probably best to ease teachability/composability.
Yes, this works similar to how Downshift stores/clears items.
Ok, I've missed that you actually clear those items in useMeasureEffect
. Clearing & pushing happens in layout effects inside two separate provided hooks. This seems like a potential foot gun for users, because there is a requirement of calling useMeasureEffect
before any useMeasureRef
, which ain't that obvious and which is important when calling both of those from within a single component.
I agree, named collections are useful especially when working with multiple measurements. Maybe useMeasureRef takes an optional id or something to identify it, then we return an array of tuples [id, node] in useMeasureEffect?
Still would argue that storing those in a map would be easier - you could maybe autogenerate IDs (when not given explicitly) with the help of WeakMaps?
Just an extra thing I've noticed now - the current API is prone to minor memory leaks. If you introduce a "pure" boundary between the useMeasureEffect
call and useMeasureRef
calls deeper in the tree there is always a possibility that those inner components will unmount and the "root" component won't get notified about it and thus it will be holding to unmounted refs.
I'm wondering - is there any particular reason why this doesnt have any deps?
https://gist.github.com/souporserious/b8f759ace04eea747382b88f0872300d#file-use-measure-jsx-L87-L89
And also a general comment - I think the API could be simplified to:
I would also suggest keeping additional refs inside a map with named keys (instead of inside an array). This would make
onMeasure
callback more useful because it would be easy to find particular refs inside the map by name. Array forces the consumer to inspect the DOM node itself or rely on a rather unstable order of elements.My main motivation to look into this was how you are consuming a "user ref" right now and I would like to focus on it.
I believe the better way is to always require a ref to be provided by a user. I think it makes things more composable and had this discussion in the past with some other folks - maybe gonna write a blog post about this "pattern" soon.
Let's look at your current code and compare it with the proposed change:
using without existing ref
using with existing ref
As we might notice - nothing has changed dramatically when it comes to LOC. What we can take out of it - with the current API creating a root ref by default inside of the
useMeasure
doesn't really bring many benefits to the user, because you still require them to calluseMeasureRef
. This is a custom logic and has to be documented and explained, whereas always requiring a ref to be provided requires much less education - everybody is already aware ofReact.useRef
. There is also no surprising behavior associated with it wherein the current API it might be surprising thatuseMeasureRef
is responsible for:Seems that there are more ways to misuse this API.
However - my main concern with this API is composability. Imagine more hooks implementing your API and the situation where you need to "hook" multiple of them to a single ref:
current API
proposed API