React ref Is Always Null on a Custom Component - forwardRef Explained
5 min read
“useRef(null), pass it down, call .focus() or .click() on it later - and it does nothing, because .current is still null. No error. No warning. The ref was never attached to anything, because the component it was passed to never asked for it.”
const btnRef = useRef(null). Pass ref={btnRef} to a component. Later,
call btnRef.current.focus() or btnRef.current.click().
btnRef.current is null. Still. After the component has mounted, after
the button is visibly on screen, after everything looks like it should be
wired up correctly.
No error gets thrown for this - until you try to call a method on null,
at which point the error is about null, not about refs, which sends a lot
of debugging time in the wrong direction.
Every other prop you pass to a component - onClick, label, value,
anything - arrives in that component's props object, and the component
decides what to do with it. ref does not work this way. React intercepts
ref before it would become a prop, and by default, for a plain function
component, there is nowhere for React to put it. So it's dropped.
Silently, as far as your UI is concerned. There's no thrown error, nothing
in the rendered output changes, no visible sign that anything went wrong.
In development you may catch a console note about it, but in production
there is nothing - and either way, the error message when you eventually
call .click() or .focus() on null points at the call site, not at the
missing forwardRef. The ref object itself - btnRef - is unaffected; it's
still a valid ref, still pointing at null, because nothing ever called
btnRef.current = someElement.
function ActionButton({ onClick, children }) { return <button onClick={onClick}>{children}</button>;}export default function ShortcutForm() { const [log, setLog] = useState([]); const btnRef = useRef(null); useEffect(() => { function handleKey(e) { if (e.key === "Enter") { if (btnRef.current) { btnRef.current.click(); } else { setLog((prev) => [...prev, "ref is null - shortcut failed"]); } } } window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, []); function handleAction() { setLog((prev) => [...prev, "Action fired"]); } return ( <div> <ActionButton ref={btnRef} onClick={handleAction}> Run Action </ActionButton> <ul>{log.map((entry, i) => <li key={i}>{entry}</li>)}</ul> </div> );}
<ActionButton ref={btnRef} onClick={handleAction}> looks completely
normal. Click the button with a mouse - handleAction fires, "Action
fired" is logged. The onClick prop works fine, because onClickis a
normal prop; ActionButton receives it and passes it to the native
<button>.
Press Enter anywhere on the page. The keydown listener fires, checks
btnRef.current - and it's null. The else branch runs: "ref is null -
shortcut failed."
ActionButton is a plain function component: function ActionButton({ onClick, children }) { ... }. It has no second parameter for a ref, and no
mechanism to receive one. React saw ref={btnRef} on <ActionButton>,
recognized that ActionButton can't accept it, and dropped it -
btnRef.current was never set to anything, so it stays at its initial value
of null.
Symptom
Clicking the button directly works perfectly - any prop-based interaction
is fine. Anything that depends on ref.current - here, a keyboard
shortcut calling .click() programmatically - hits null and either does
nothing or falls into an explicit "ref is null" error path, with no React
warning pointing at the cause.
forwardRef wraps the component and changes its signature: instead of just
(props), the inner function receives (props, ref). React now knows what
to do with a ref passed to <ActionButton> - it hands it to this second
argument, and the component is responsible for attaching it to something
real, here the native <button> via ref={ref}.
Now btnRef.current is the actual <button> DOM element. btnRef.current !== null, so btnRef.current.click() runs, which fires the button's
onClick - handleAction - the same as a real mouse click would.
The Sensei's Hint
forwardRef doesn't change what the component renders or how its other
props behave - onClick, children, everything else works exactly as
before. It only changes what happens to a ref passed to this component:
from "silently dropped" to "passed through to whatever native element you
attach it to."
"Search Input Never Gains Focus" is the same fix applied to a different
goal - calling .focus() instead of .click():
function SearchInput({ placeholder }) { return <input placeholder={placeholder} />;}
function handleActivate() { if (inputRef.current) { inputRef.current.focus(); setStatus("active"); } else { setStatus("cannot focus"); }}
Same shape: SearchInput is a plain function component, ref={inputRef}
is dropped, inputRef.current is null, handleActivate always falls into
the else branch and reports "cannot focus" - even though the <input> is
right there on screen and a real DOM .focus() call would work fine on it
if the ref actually pointed to it.
Same fix: wrap with forwardRef, attach the forwarded ref to the
<input>. inputRef.current becomes the real input element,
.focus() works, and status correctly reads "active."
Constraints
Any time you write <SomeComponent ref={someRef} /> where SomeComponent
is your own function component (not a built-in element like <input> or
<div>), check that SomeComponent is wrapped in forwardRef and
explicitly attaches that ref somewhere. If it's a plain function SomeComponent(props) { ... }, the ref has nowhere to go - and
someRef.current will be null for the component's entire lifetime, with
no error telling you why.
"Button Ignores Keyboard Shortcut" sets up the failure clearly: the log
literally prints "ref is null - shortcut failed" when you press Enter,
which is as direct a diagnostic as this bug ever gives you. "Search Input
Never Gains Focus" is the same root cause with a quieter symptom - a status
label reading "cannot focus" instead of an explicit ref-is-null message -
useful for recognizing the pattern when the code doesn't spell it out for
you.