← Blog9 min read#useRef#forwardRef#useImperativeHandle

React Refs Deep Dive: useRef, forwardRef, useImperativeHandle

Master React refs from basic DOM access to cross-component API exposure - useRef, forwardRef, and useImperativeHandle explained with real examples.

Developer writing React code with hooks on a dark monitor

Why Refs Exist (And When You Actually Need One)

React's mental model is pretty clean: state changes trigger re-renders, the UI follows data. So where do refs fit? They're the escape hatch for everything the VDOM can't handle - direct DOM access, persisting a mutable value across renders without triggering re-renders, or storing a timer ID you'll need to clear later.

Honestly, most developers reach for useRef too early. If you can solve something with state, do that. But for things like focusing an input programmatically, measuring a DOM node's width at 0ms on mount, or holding a WebSocket instance, refs are exactly right.

The key mental shift is this: a ref is a plain JavaScript object with a single .current property. React doesn't watch it. Changing .current won't schedule a render. That's the feature, not a bug - you want some values to live outside React's render cycle entirely.

Worth noting: in React 19 (released in late 2024), the ref prop works directly on function components without needing forwardRef. But if you're on React 18 or earlier - which a lot of production apps still are - you need forwardRef. This article covers both approaches so you're covered either way.

useRef: The Basics and the Traps

useRef returns a stable object: { current: initialValue }. That object is the same reference across every render of your component. This is what makes it useful for DOM access AND for values you want to persist between renders without causing re-renders when they change.

The classic DOM access pattern looks like this: ``tsx import { useRef, useEffect } from 'react'; export function AutoFocusInput() { const inputRef = useRef<HTMLInputElement>(null); useEffect(() => { inputRef.current?.focus(); }, []); return <input ref={inputRef} type="text" placeholder="I focus on mount" />; } ` You pass the ref to the JSX element, and React populates .current with the real DOM node after the component mounts. The optional chaining on ?.focus() is a good habit - during server rendering or if the element was conditionally rendered, .current will be null`.

A less obvious but equally important pattern is using useRef as a stable container for values you don't want to trigger re-renders. Think: previous render values, callback refs you want to always be fresh without adding to a useCallback dependency array, or an interval ID. ``tsx function useInterval(callback: () => void, delay: number) { const savedCallback = useRef(callback); // Keep the ref up to date on every render useEffect(() => { savedCallback.current = callback; }); useEffect(() => { const id = setInterval(() => savedCallback.current(), delay); return () => clearInterval(id); }, [delay]); } `` This pattern (popularized by Dan Abramov in 2019) lets you use an always-fresh callback inside an interval without restarting the interval every time the callback changes. Without the ref, you'd either have stale closures or a dependency-array that nukes and rebuilds the interval constantly.

Quick aside: TypeScript users should type their refs correctly. useRef<HTMLInputElement>(null) gives you RefObject<HTMLInputElement> where .current is HTMLInputElement | null. useRef<number>(0) gives you MutableRefObject<number> where .current is just number. The distinction matters when TypeScript complains about nullability.

What you can't do: read .current during render and expect it to be stable. If you're reading a ref in the render path, you're probably looking for state. Refs are for effects and event handlers.

forwardRef: Exposing a DOM Ref Through Your Component

Here's the problem forwardRef solves. You build a <TextInput> component wrapping a native <input>. A parent wants to call .focus() on the underlying <input>. Without forwardRef, the parent has no way to reach that DOM node - ref on a custom component doesn't work in React 18 and earlier.

The pattern looks like this: ``tsx import { forwardRef } from 'react'; type TextInputProps = React.InputHTMLAttributes<HTMLInputElement> & { label?: string; }; const TextInput = forwardRef<HTMLInputElement, TextInputProps>( ({ label, ...props }, ref) => { return ( <div className="field"> {label && <label>{label}</label>} <input ref={ref} {...props} /> </div> ); } ); TextInput.displayName = 'TextInput'; ` The generic arguments to forwardRef are <RefType, PropsType> - in that order. TypeScript will enforce that the parent's useRef is typed for HTMLInputElement, which is exactly what you want. Always set displayName or React DevTools will show ForwardRef` as the component name, which is annoying when debugging a tree with 20 components.

In React 19, forwardRef is no longer needed. You can just accept ref as a regular prop: ``tsx // React 19+ function TextInput({ label, ref, ...props }: TextInputProps & { ref?: React.Ref<HTMLInputElement> }) { return ( <div className="field"> {label && <label>{label}</label>} <input ref={ref} {...props} /> </div> ); } ` Cleaner. But don't go ripping out your forwardRef` calls in a shared component library if you need to support React 18 consumers - you'll break their apps silently.

In practice, most design systems use forwardRef on every leaf component that wraps a DOM element. If you're building a component library - maybe something like the components in Empire UI - you'd do this for buttons, inputs, textareas, selects, anything that a parent might reasonably want DOM access to.

useImperativeHandle: Exposing a Custom API Instead of the Raw DOM

Sometimes forwarding the raw DOM ref isn't what you want. Maybe your component wraps a complex animation, a video player, or a multi-step form wizard. You want the parent to call .play(), .reset(), or .next() - not dispatchEvent(new MouseEvent('click')) on some internal node. That's what useImperativeHandle is for.

useImperativeHandle works alongside forwardRef. You accept the forwarded ref, and instead of putting it directly on a DOM node, you use useImperativeHandle to define exactly what the parent gets. ``tsx import { forwardRef, useRef, useImperativeHandle } from 'react'; type WizardHandle = { next: () => void; prev: () => void; reset: () => void; currentStep: () => number; }; type WizardProps = { steps: React.ReactNode[]; }; const Wizard = forwardRef<WizardHandle, WizardProps>(({ steps }, ref) => { const [step, setStep] = React.useState(0); useImperativeHandle(ref, () => ({ next: () => setStep(s => Math.min(s + 1, steps.length - 1)), prev: () => setStep(s => Math.max(s - 1, 0)), reset: () => setStep(0), currentStep: () => step, }), [steps.length, step]); return <div>{steps[step]}</div>; }); ` The parent then does: `tsx const wizardRef = useRef<WizardHandle>(null); // ... <Wizard ref={wizardRef} steps={[<Step1 />, <Step2 />, <Step3 />]} /> <button onClick={() => wizardRef.current?.next()}>Next</button> ` The parent never touches internal state. It can't accidentally read step` directly. The interface is exactly what you designed it to be.

The third argument to useImperativeHandle is a dependency array, just like useEffect. If your exposed methods close over state or props, list those dependencies. Miss them and you'll get stale closures - the parent calls .currentStep() and gets a number that's render-cycle-old. It's a subtle bug that's annoying to track down.

Look, useImperativeHandle is powerful but it's not free. You're creating an imperative API in a declarative framework. Every consumer of that API now has to manage the ref lifecycle. Use it for true imperative scenarios - focus management, animation controls, media playback - not as a shortcut to avoid lifting state. If you can express it as props and callbacks, do that instead.

That said, for things like autofocus-on-error (where a parent form needs to tell a deep child to focus its first invalid field), useImperativeHandle is genuinely the cleanest tool. No prop drilling, no context gymnastics, just a clean .focusFirstError() call.

Callback Refs: The Pattern Most Devs Skip

You probably know you can pass a useRef object as the ref prop. But ref also accepts a function - a callback ref. React calls it with the DOM node when the element mounts, and calls it with null when it unmounts. No useEffect required to observe mount/unmount.

``tsx function MeasuredBox() { const [width, setWidth] = React.useState(0); const measuredRef = React.useCallback((node: HTMLDivElement | null) => { if (node !== null) { setWidth(node.getBoundingClientRect().width); } }, []); return ( <> <div ref={measuredRef} style={{ padding: '16px', background: '#111' }}> I am {Math.round(width)}px wide </div> </> ); } ` The useCallback with an empty dependency array is important here. If you inline the function directly in JSX, React will see a new function reference every render and call the callback ref with null then the node on each render - which triggers another state update, causing an infinite loop. Wrap it in useCallback`.

Callback refs shine when you need to react to an element appearing in the DOM conditionally - think dynamic lists, portals, or items rendered by a virtual scroller. With a useRef object, you'd need useEffect with just the right dependencies to detect mount. Callback refs handle this automatically.

Worth noting: you can also use callback refs to collect refs for a list of elements without needing useRef(null) per item, which gets unwieldy. Store them in a Map or plain object inside a ref: ``tsx const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const getRef = (id: string) => (node: HTMLDivElement | null) => { if (node) rowRefs.current.set(id, node); else rowRefs.current.delete(id); }; // In render: {items.map(item => ( <div key={item.id} ref={getRef(item.id)}>{item.label}</div> ))} ` This pattern avoids creating N separate useRef` calls for N dynamic items - a common need when building things like animated list items or canvas-backed UI components.

Putting It Together: A Real Component Example

Let's build something concrete - an AnimatedDrawer component that a parent can open, close, and query the current state of, without the parent holding any of that state itself. It's the kind of component you'd find in a polished UI library. ``tsx import { forwardRef, useRef, useState, useImperativeHandle, useEffect, } from 'react'; export type DrawerHandle = { open: () => void; close: () => void; toggle: () => void; isOpen: () => boolean; }; type DrawerProps = { children: React.ReactNode; width?: number; }; const AnimatedDrawer = forwardRef<DrawerHandle, DrawerProps>( ({ children, width = 320 }, ref) => { const [open, setOpen] = useState(false); const drawerRef = useRef<HTMLDivElement>(null); useImperativeHandle(ref, () => ({ open: () => setOpen(true), close: () => setOpen(false), toggle: () => setOpen(o => !o), isOpen: () => open, }), [open]); return ( <div ref={drawerRef} style={{ position: 'fixed', right: 0, top: 0, height: '100%', width, transform: open ? 'translateX(0)' : translateX(${width}px), transition: 'transform 240ms cubic-bezier(0.4, 0, 0.2, 1)', background: '#1a1a1a', boxShadow: open ? '-8px 0 32px rgba(0,0,0,0.4)' : 'none', }} > {children} </div> ); } ); AnimatedDrawer.displayName = 'AnimatedDrawer'; export default AnimatedDrawer; ` Usage in a parent: `tsx const drawerRef = useRef<DrawerHandle>(null); <button onClick={() => drawerRef.current?.toggle()}>Menu</button> <AnimatedDrawer ref={drawerRef} width={360}> <nav>...</nav> </AnimatedDrawer> ``

Notice the width = 320 default - that's a real 320px default, not a magic number. The 240ms transition is deliberate too: fast enough to feel responsive, slow enough that the user can track the motion. These aren't aesthetic preferences, they're grounded in motion design guidelines from Material Design 3 (2022).

The parent component owns zero state about the drawer being open or closed. If it needs to react to the drawer state, that's when you'd switch to lifting state up or using context. The imperative API here is a trade-off - less re-render churn in the parent, but the parent can't declaratively derive UI from drawer state. Know which trade-off fits your situation.

If you're building design system components like this and want inspiration, the glassmorphism components on Empire UI or the box shadow generator are worth looking at for the aesthetic layer on top of this kind of structure.

Common Mistakes and How to Avoid Them

The most common ref mistake: reading .current during render and expecting it to reflect the current DOM state. Refs are populated after render. If you're doing if (myRef.current) { doSomething() } in the component body outside an effect or event handler, you're going to get burned.

Second most common: forgetting the dependency array in useImperativeHandle. Or listing dependencies correctly but then having the exposed function close over something that's in a closure but not in the array. The symptom is that parent code calls a method and gets a stale value. Add step or whatever the closure captures to the dep array and it fixes instantly.

Third: over-using refs for state. If a value should cause a re-render when it changes, it's state. If you find yourself calling forceUpdate() or using some hack because your ref changed but the UI didn't update - that's a signal to move the value into useState. ``tsx // Wrong - count changes won't re-render const count = useRef(0); count.current++; // Right const [count, setCount] = useState(0); setCount(c => c + 1); ``

One more thing - in strict mode (enabled by default in React 18+ dev builds), effects run twice on mount. If you're initializing something in an effect that stores into a ref, make sure your cleanup function handles the double-invoke. useImperativeHandle doesn't have this issue, but useEffect(() => { myRef.current = new SomeClass(); }, []) can behave unexpectedly if SomeClass has side effects at construction time.

Finally: if you're passing refs around a lot and finding it painful, that's a code smell. Deep ref passing usually means you're trying to coordinate distant components imperatively. Context, state management, or rethinking your component boundaries will serve you better long-term. Tools like Empire UI are built with these patterns in mind - refs are exposed where it genuinely makes sense, not as a default.

FAQ

What's the difference between useRef and useState?

Changing a useRef value doesn't trigger a re-render - it's a mutable box React ignores. useState schedules a re-render when updated. Use refs for DOM access, timers, and values you need to persist without re-rendering; use state for anything that should update the UI.

Do I still need forwardRef in React 19?

No - React 19 lets you accept ref as a plain prop in function components. But if you're building a library that supports React 18, keep using forwardRef to avoid breaking consumers on older versions.

When should I use useImperativeHandle instead of props and callbacks?

Use it for genuinely imperative scenarios like focus management, animation controls, or media playback where the action is triggered on demand, not derived from state. If you can express the behavior as a prop or event handler, that's almost always cleaner.

Why does my callback ref fire twice in development?

React 18 Strict Mode double-invokes effects to surface side-effect bugs. Callback refs also re-fire if their function reference changes between renders - wrap your callback ref in useCallback with a stable dependency array to prevent this.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

React Architecture & Patterns: The Complete 2026 Guide β†’Drag and Drop in React 2026: dnd-kit vs react-beautiful-dnd β†’React Toast Notifications: Build a Sonner-Style Toast System β†’What Is Glassmorphism? A Free React + Tailwind Guide β†’