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 useEffect
s 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 useEffect
s 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
forwardedRef
if 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
createRef
function. - Simple because uses only the
useState
hook, 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])
useImperativeHandle
customizes the instance value that is exposed to parent components when usingref
. As always, imperative code using refs should be avoided in most cases.useImperativeHandle
should 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.current
andref.current
will point to the same DOM element. - If a ref is not forwarded,
forwardedRef
will still benull
, butref
will 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
useImperativeHandle
hook is designed for customizing the value of the forwarded ref, so using it like this feels correct. - ESLint knows that the
ref
variable is stable, so it doesn't need to be included in theuseEffect
's dependency array, making the code clearer. (Though ESLint won't know thatref
is stable when using the custom hook.)
Cons:
- The
useImperativeHandle
hook 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: