Four ways to use a fallback ref with React.forwardRef
Published on in JavaScript and React
A component wrapped in React.forwardRef
normally expects to receive a ref from its parent component.
What if you want the component to have a ref
even if it doesn't receive one from its parent?
Table of contents
The problem
React.forwardRef allows passing a ref to a child component:
function Parent() {
const ref = useRef(null)
useEffect(() => {
console.log(ref.current)
}, [])
return <Child ref={ref} />
}
const Child = React.forwardRef((props, forwardedRef) => {
useEffect(() => {
console.log(forwardedRef?.current)
}, [forwardedRef])
return <div ref={forwardedRef}>...</div>
})
When rendering <Parent />,
both useEffects log the <div> element from the Child component:
<Parent />
// Logs `<div>` twice
But when rendering <Child /> on its own,
the Child's useEffect logs undefined
because no ref is passed/forwarded:
<Child />
// Logs `undefined` once 😿
More specifically:
forwardedRef is null,
so forwardedRef?.current is undefined.
What if you want Child to have a ref to its <div> element
even when no ref is forwarded to Child?
You can create a new ref in Child,
but you can't attach both refs to the <div>:
const Child = React.forwardRef((props, forwardedRef) => {
const ref = useRef(null)
return (
<div
// 🛑 JSX elements cannot have multiple attributes with the same name
ref={forwardedRef}
ref={ref}
>
...
</div>
)
})
Desired outcome
When rendering <Parent />,
both useEffects should still log the <div> element from the Child component:
<Parent />
// Logs `<div>` twice
When rendering <Child> on its own,
its useEffect should log the <div> element:
<Child />
// Logs `<div>` once
Four solutions
useRef + ||
Create a fallback ref with useRef
and use it if forwardedRef is null:
const Child = React.forwardRef((props, forwardedRef) => {
const fallbackRef = useRef(null)
const ref = forwardedRef || fallbackRef
useEffect(() => {
console.log(ref.current)
}, [ref])
return <div ref={ref}>...</div>
})
Or as a custom hook:
const Child = React.forwardRef((props, forwardedRef) => {
const ref = useFallbackRef(forwardedRef)
useEffect(() => {
console.log(ref.current)
}, [ref])
return <div ref={ref}>...</div>
})
function useFallbackRef(forwardedRef) {
const fallbackRef = useRef(null)
return forwardedRef || fallbackRef
}
Pros:
- Very simple and clear. Maybe the simplest solution.
Cons:
- The fallback ref is created even when it's not needed, but it's probably not a big deal.
I found this solution via
brunoscopelliti/use-forward-ref on GitHub,
which was the only npm package found via
searching "react fallback ref" on npmjs.com.
Lazy useState + conditional createRef
Create a fallback ref with React.createRef
if forwardedRef is null.
To avoid creating a new ref every time the Child component is re-rendered,
wrap the conditional ref creation in useState
and use lazy initial state:
const Child = React.forwardRef((props, forwardedRef) => {
const [ref] = useState(() => forwardedRef || React.createRef())
useEffect(() => {
console.log(ref.current)
}, [ref])
return <div ref={ref}>...</div>
})
Or as a custom hook:
const Child = React.forwardRef((props, forwardedRef) => {
const ref = useFallbackRef(forwardedRef)
useEffect(() => {
console.log(ref.current)
}, [ref])
return <div ref={ref}>...</div>
})
function useFallbackRef(forwardedRef) {
return useState(() => forwardedRef || React.createRef())[0]
}
Pros:
- Simple and reads like English:
use
forwardedRefif it exists (i.e. if it's notnull), otherwise create a new ref. - The fallback ref is created only when necessary. (Probably doesn't matter though.)
Cons:
- It's mildly confusing to mix hooks and the non-hook
createReffunction. - Simple because uses only the
useStatehook, though you do need to know about and understand lazy initial state.
I came up with this solution myself. 🤙
useImperativeHandle
React's useImperativeHandle hook
allows customizing the value of a forwarded ref.
The docs for the hook are very terse (emphasis added and example code slightly modified):
useImperativeHandle(ref, createHandle, [deps])
useImperativeHandlecustomizes the instance value that is exposed to parent components when usingref. As always, imperative code using refs should be avoided in most cases.useImperativeHandleshould be used withforwardRef:const FancyInput = React.forwardRef((props, forwardedRef) => { const ref = useRef() useImperativeHandle(forwardedRef, () => ({ focus: () => ref.current.focus(), })) return <input ref={ref} /> })In this example, a parent component that renders
<FancyInput ref={inputRef} />would be able to callinputRef.current.focus().
In the quoted example,
inputRef.current has only the focus method.
To expose all methods and properties of the DOM element to the parent ref,
expose ref.current as-is:
useImperativeHandle(forwardedRef, () => ref.current)
Then:
- If a ref is forwarded,
forwardedRef.currentandref.currentwill point to the same DOM element. - If a ref is not forwarded,
forwardedRefwill still benull, butrefwill act as a fallback ref.
Put together:
const Child = React.forwardRef((props, forwardedRef) => {
const ref = useRef(null)
useImperativeHandle(forwardedRef, () => ref.current)
useEffect(() => {
console.log(ref.current)
}, []) // `ref` is not needed here
// Notice we are using `ref` here, not `forwardedRef`
return <div ref={ref}>...</div>
})
Or as a custom hook:
const Child = React.forwardRef((props, forwardedRef) => {
const ref = useFallbackRef(forwardedRef)
useEffect(() => {
console.log(ref.current)
}, [ref]) // `ref` is needed again here
return <div ref={ref}>...</div>
})
function useFallbackRef(forwardedRef) {
const ref = useRef(null)
useImperativeHandle(forwardedRef, () => ref.current)
return ref
}
Pros:
- The
useImperativeHandlehook is designed for customizing the value of the forwarded ref, so using it like this feels correct. - ESLint knows that the
refvariable is stable, so it doesn't need to be included in theuseEffect's dependency array, making the code clearer. (Though ESLint won't know thatrefis stable when using the custom hook.)
Cons:
- The
useImperativeHandlehook is exotic and probably only rarely used. If you have never heard of it before, you can't know how it works at a first glance. (Compared touseState, which is maybe the most basic hook and should be familiar to any React dev.) - The fallback ref is created even when it's not needed, but it's probably not a big deal.
I found this solution via
Jake Trent's blog post Fallback Ref in React.
It introduced me to the useImperativeHandle hook
and has good musings about why the hook is lame.
Merge refs
Use a utility like react-merge-refs to merge the two refs:
import mergeRefs from 'react-merge-refs'
const Child = React.forwardRef((props, forwardedRef) => {
const ref = useRef()
useEffect(() => {
console.log(ref.current)
}, [])
return <div ref={mergeRefs([forwardedRef, ref])}>...</div>
})
Under the hood mergeRefs returns a callback ref:
function mergeRefs(refs) {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value)
} else if (ref != null) {
ref.current = value
}
})
}
}
If forwardedRef is null,
mergeRefs simply skips it.
Pros:
- More versatile because supports merging more than two refs.
Cons:
- Requires a 3rd party library unless you re-implement the utility yourself. And if you are going to re-implement it yourself, there are simpler solutions (unless you need to merge more than two refs).
The use-callback-ref library
provides a similar mergeRefs function
as well as a useMergeRefs hook.
Which solution to use?
If you need to merge more than two refs,
use a utility like react-merge-refs or use-callback-ref.
Otherwise I would prefer the first or the second solution. Both are simple and straightforward. No point in installing a dependency if you just want to create a fallback ref.
Initially the useImperativeHandle solution felt the most correct to me
because the hook is designed for customizing the value of the forwarded ref.
However, the solution is more difficult to understand
because the hook is so exotic and rarely used.
One more point to consider:
what if,
during the lifecycle of Child (the component wrapped in React.forwardRef),
forwardedRef is sometimes null and sometimes a ref object?
I haven't tested how the different solutions would react to that.
This might or might not be relevant in your case.
Invalid solutions
Knowing what doesn't work can be very useful as well.
Conditional useRef
Creating a fallback ref with useRef if forwardedRef is null might work:
const Child = React.forwardRef((props, forwardedRef) => {
// 🛑 Not allowed!
// Calling hooks conditionally breaks the rules of hooks.
const ref = forwardedRef || useRef()
useEffect(() => {
console.log(ref.current)
}, [ref])
return <div ref={ref}>...</div>
})
...but it might also not work because calling hooks conditionally breaks the rules of hooks.
Conditional createRef
React.createRef is not a hook,
so it can be called conditionally:
const Child = React.forwardRef((props, forwardedRef) => {
// 🛑 A new ref is created on every re-render if `forwardedRef` is `null`
const ref = forwardedRef || React.createRef()
useEffect(() => {
console.log(ref.current)
}, [ref])
return <div ref={ref}>...</div>
})
However,
if forwardedRef is null,
a new ref is created every time the Child component is re-rendered.
Furthermore,
ref is not stable,
so it needs to be included in the useEffect's dependency array.
Because of this, the useEffect will run on every re-render.
Omitting ref from the dependency array
and ignoring the "exhaustive-deps" ESLint rule
is not a proper solution.
Ignoring the rule is confusing and opens the door for further problems.
useRef + conditional createRef
To make the ref from the previous invalid solution stable,
we can wrap it in another useRef
and immediately grab its current value (i.e. forwardedRef or the newly-created ref):
const Child = React.forwardRef((props, forwardedRef) => {
// 🛑 A new ref is created and discarded on every re-render if `forwardedRef` is `null`
const ref = useRef(forwardedRef || React.createRef()).current
useEffect(() => {
console.log(ref.current)
}, [ref]) // `ref` must be included here but is stable
return <div ref={ref}>...</div>
})
However,
if forwardedRef is null,
a new ref is still created every time the Child component is re-rendered;
it's just discarded immediately.
Plus the code looks ugly and unclear.
Further resources
This blog post contains these links: