facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
227.13k stars 46.31k forks source link

Refs merging/combining #29757

Open FrameMuse opened 3 months ago

FrameMuse commented 3 months ago

Summary

I wanted to use ref from props while also creating a local ref as a fallback, but I got into this problem https://github.com/facebook/react/issues/17200.

I know this could be resolved with a little code, though I think this behavior should belong natively to React. There is even a package for that https://www.npmjs.com/package/react-merge-refs with ~1 million downloads/week, which confirms necessity of this for the React users.

I would propose something like

function Component(props: { ref?: Ref<HTMLDivElement> }) {
  const fallbackRef = useRef<HTMLDivElement>(null)
  return <div ref={[props.ref, fallbackRef]} />
}

or at least this for fallback cases

function Component(props: { ref?: Ref<HTMLDivElement> }) {
  const elementRef = useRef<HTMLDivElement>(props.ref)
  return <div ref={elementRef} />
}

or hook/helper to resolve refs concurrency

function Component(props: { ref?: Ref<HTMLDivElement> }) {
  const elementRef = useRef<HTMLDivElement>(null)
  const combinedRef = useCombinedRef([props.ref, elementRef])

  return <div ref={combinedRef} />
}
function Component(props: { ref?: Ref<HTMLDivElement> }) {
  const elementRef = useRef<HTMLDivElement>(null)
  return <div ref={combineRefs([props.ref, elementRef])} />
}

What do you think? Am I going too far with it? 😅

monikkacha commented 3 months ago

Hey @FrameMuse, I checked your reference npm package and whole scenario and it seems like worth adding, but I haven't gotten around to such a scenario as when I need to have a ref for parent and child both component, can you please explain the use case further more so I can better understand the requirement.

I am willing to work on this feature and raise PR if the scenario is really useful and maintainers are happy to add this feature.

FrameMuse commented 3 months ago

@monikkacha

Here's two use-cases and my real-world example, hope this would be helpful.

Fallback

In React 19 I can use ref in props, put in an element and use it in the component itself while giving an outside access to it as well.

function Component(props: { ref: Ref<HTMLDivElement> }) {
  useClickAway(props.ref)
  return <div ref={props.ref} />
}

function Parent() {
  const elementRef = useRef<HTMLDivElement>(null)
  useLayoutEffect(() => {
    elementRef.current.clientHeight // Will actually work now.
  }, [])
  return <Component ref={elementRef} />
}

But the problem comes when you want to make that ref to be optional.

function Component(props: { ref?: Ref<HTMLDivElement> }) {
  useClickAway(props.ref) // Won't work if `ref` is not passed.
  return <div ref={props.ref} />
}

// Click Away will work only for component that is receiving a ref.
<Component ref={elementRef} />
<Component />

That's why we need to use a fallback ref.

function Component(props: { ref?: Ref<HTMLDivElement> }) {
  const elementRef = useRef<HTMLDivElement>(null)
  const combinedRef = combineRefs([elementRef , props.ref])

  useClickAway(elementRef) // Now will work regardless of passed `ref`.
  return <div ref={combinedRef} />
}

// Click Away will work for both components.
<Component ref={elementRef} />
<Component />

Concurrent

Concurrent happens when you have ref that is coming from a parent but your element also have a ref, you may say

Just use the ref that the parent is passing (-_-)/

This will actually work, but only in cases when the parent is passing an object. If it's a callback, it won't work that easily, you will have to use useEffect to call passing ref, which is not ideal and may have consequences.

To solve it, you will need to pass a callback to the element and call or assign your refs. That's why there is a library that provides a function for that.

Real-World Example

I used TanStack Virtual to virtualize the list and I used ref to measure element dynamically, but I wanted the components of list to have possibility of click away.

In this case I had ref as a callback though components needed a ref object. It's somewhat close to a fallback case.

interface ItemsProps {
  items: object[]
}

function Items(props: ItemsProps) {
  const elementRef = useRef<HTMLDivElement>(null)

  const scrolling = useContext(scrollingContext)
  const virtual = useVirtualizer({
    count: props.items.length,
    gap: 8,
    getScrollElement: () => scrolling.elementRef.current,
    estimateSize: () => 60,
    measureElement: element => element.scrollHeight,
    overscan: 5
  })

  const items = virtual.getVirtualItems()

  return (
    <div className="items" style={{ height: virtual.getTotalSize(), zIndex: 0 }} ref={elementRef}>
      {items.map(item => (
        <div style={{ height: item.size, transform: `translateY(${item.start}px)`, zIndex: -item.index }} key={item.key}>
          <Item {...props.items[item.index]} index={item.index} ref={virtual.measureElement} />
        </div>
      ))}
    </div>
  )
}

interface ItemProps {
  index: number
  ref?: Ref<HTMLDivElement>
}
function Item(props: ItemProps) {
  const elementRef = useRef<HTMLDivElement>(null)
  const combinedRef = combineRefs([elementRef, props.ref])

  const [expanded, setExpanded] = useState(false)
  useClickAway(elementRef, () => setExpanded(false))

  return <div onClick={() => setExpanded(true)} ref={combinedRef} />
}

The code is simplified and generalized to not disclose any source code

monikkacha commented 3 months ago

@eps1lon is it ok if i work on this issue and raise PR ?

eps1lon commented 3 months ago

Sure, but no promises when this will be reviewed or if it will be merged at all.

monikkacha commented 3 months ago

That sounds depressing 😅, but I will still work on the feature and raise PR later on.