Skip to content

Instantly share code, notes, and snippets.

@souporserious
Last active February 29, 2020 15:23
Show Gist options
  • Save souporserious/b8f759ace04eea747382b88f0872300d to your computer and use it in GitHub Desktop.
Save souporserious/b8f759ace04eea747382b88f0872300d to your computer and use it in GitHub Desktop.
use-measure hook for collecting component measurements
// 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]
}
@Andarist
Copy link

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:

// this would be an effect that would register & unregister refs appropriately
type UseAdditionalRef = (additionalRef: React.Ref<HTMLElement>) => void

export function useMeasure(
  rootRef: React.Ref<HTMLElement>,
  onMeasure: HTMLElement[],
  inputs?: React.InputIdentityList,
): UseAdditionalRef

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
// current
function Singular({ value }) {
  const [width, setWidth] = useState(-1)
  const [useMeasureRef, useMeasureEffect] = useMeasure()
  const ref = useMeasureRef()
  useMeasureEffect(
    ([node]) => {
      setWidth(node.offsetWidth)
    },
    [value]
  )
  return <div ref={ref}>Inside measurement: {width}</div>
}

// after change
function Singular({ value }) {
  const [width, setWidth] = useState(-1)
  const ref = React.useRef()
  useMeasure(
    ref,
    ([node]) => {
      setWidth(node.offsetWidth)
    },
    [value]
  )
  return <div ref={ref}>Inside measurement: {width}</div>
}
using with existing ref
// current
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>
}

// after change
function Singular({ ref, value }) {
  const [width, setWidth] = useState(-1)
  useMeasure(
    ref,
    ([node]) => {
      setWidth(node.offsetWidth)
    },
    [value]
  )
  return <div>Outside measurement: {width}</div>
}

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 call useMeasureRef. 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 of React.useRef. There is also no surprising behavior associated with it wherein the current API it might be surprising that useMeasureRef is responsible for:

  • providing a ref if no has been supplied by the user
  • pushing more refs into the internal array
  • subscribing to observers

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
function Singular({ value }) {
  const [width, setWidth] = useState(-1)
  const [useMeasureRef, useMeasureEffect] = useMeasure()
  const ref = useMeasureRef()
  useMeasureEffect(
    ([node]) => {
      setWidth(node.offsetWidth)
    },
    [value]
  )
  const useOnClickOutsideRef = useOnClickOutside(() => console.log('clicked'))
  useOnClickOutsideRef(ref)
  return <div ref={ref}>Inside measurement: {width}</div>
}
proposed API
function Singular({ value }) {
  const [width, setWidth] = useState(-1)
  const ref = React.useRef()
  useMeasure(
    ref,
    ([node]) => {
      setWidth(node.offsetWidth)
    },
    [value]
  )
  useOnClickOutside(ref, () => console.log('clicked'))
  return <div ref={ref}>Inside measurement: {width}</div>
}

@souporserious
Copy link
Author

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.

@Andarist
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment