Skip to main content

useRef and DOM manipulation

In this chapter, you learn about the useRef hook, used for direct DOM access and holding values, and related APIs.

What you'll learn in this chapter

  • The two uses of useRef (DOM references and holding values)
  • The difference from useState
  • Ref forwarding (React 19's ref props / forwardRef in React 18 and earlier) and useImperativeHandle
  • When to use ref and when to avoid it
info

This chapter uses the project created in Chapter 3. Start the development server with npm run dev and study while writing code.

What is useRef

useRef is a hook that holds a value across renders and does not re-render the component when you change it. It has two main uses.

  1. References to DOM elements: such as focusing an input element
  2. Holding values: managing values you don't want to trigger a re-render, such as holding a timer ID
info

The "uncontrolled components" touched on in Chapter 10 use this useRef to get values directly from the DOM. For managing form values, useState (controlled components) is common, but things like file input require useRef.

Basic usage

References to DOM elements

import { useRef } from 'react'

function TextInput() {
// Create a ref object with useRef<type>(initial value)
// <HTMLInputElement> is the type of the DOM element to reference
// null is the initial value (because the DOM element doesn't exist yet)
const inputRef = useRef<HTMLInputElement>(null)

const handleClick = () => {
// Access the DOM element with ref.current
// ?. is optional chaining (skips if null)
inputRef.current?.focus()
}

return (
<div>
{/* Associate the DOM element with the ref object via the ref attribute */}
<input ref={inputRef} type="text" />
<button onClick={handleClick}>Focus</button>
</div>
)
}

The structure of useRef

const ref = useRef(initialValue)

// The value is stored in ref.current
console.log(ref.current) // initialValue

The difference between useRef and useState

FeatureuseRefuseState
Re-render on value changeNoYes
Accessing the valueref.currentDirectly
Main usesDOM references, holding the previous valueState reflected in the UI
function Comparison() {
// useState: changing the value triggers a re-render
const [stateCount, setStateCount] = useState(0)
// useRef: changing the value does not trigger a re-render
const refCount = useRef(0)

const handleStateClick = () => {
setStateCount(stateCount + 1) // triggers a re-render → the screen updates
console.log('state:', stateCount + 1)
}

const handleRefClick = () => {
refCount.current += 1 // doesn't trigger a re-render → the screen doesn't update
console.log('ref:', refCount.current)
}

return (
<div>
<p>State: {stateCount}</p>
{/* Even if refCount.current changes, without a re-render it isn't reflected on screen */}
<p>Ref: {refCount.current}</p>
<button onClick={handleStateClick}>State +1</button>
<button onClick={handleRefClick}>Ref +1</button>
</div>
)
}

Practical examples

Focusing an input form

function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)

// Focus the email field on mount
useEffect(() => {
emailRef.current?.focus()
}, [])

const handleEmailKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
passwordRef.current?.focus()
}
}

return (
<form>
<input
ref={emailRef}
type="email"
placeholder="Email"
onKeyDown={handleEmailKeyDown}
/>
<input
ref={passwordRef}
type="password"
placeholder="Password"
/>
<button type="submit">Log in</button>
</form>
)
}

Let's try it: autofocus

Create a login form where the input field is automatically focused when the page loads.

Hint
  1. Create a ref with useRef<HTMLInputElement>(null)
  2. Call focus() on mount (an empty dependency array) in useEffect
  3. Moving to the password field with the Enter key is convenient
Answer and explanation
import { useRef, useEffect } from 'react'
import './App.css'

function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)

useEffect(() => {
emailRef.current?.focus()
}, [])

const handleEmailKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
passwordRef.current?.focus()
}
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('Login process')
}

return (
<form onSubmit={handleSubmit}>
<div>
<input
ref={emailRef}
type="email"
placeholder="Email"
onKeyDown={handleEmailKeyDown}
/>
</div>
<div>
<input
ref={passwordRef}
type="password"
placeholder="Password"
/>
</div>
<button type="submit">Log in</button>
</form>
)
}

export default function App() {
return <LoginForm />
}

By making useEffect's dependency array empty, focus() runs once on mount. We also added a feature to move to the password field with the Enter key.

Video player

function VideoPlayer() {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)

const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
setIsPlaying(!isPlaying)
}
}

return (
<div>
<video ref={videoRef} src="/video.mp4" />
<button onClick={handlePlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
)
}

Controlling the scroll position

function ScrollToBottom() {
const bottomRef = useRef<HTMLDivElement>(null)
const [messages, setMessages] = useState<string[]>([])

const addMessage = () => {
setMessages(prev => [...prev, `Message ${prev.length + 1}`])
}

// Scroll to the bottom when a message is added
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

return (
<div>
<div style={{ height: '200px', overflow: 'auto' }}>
{messages.map((msg, i) => (
<p key={i}>{msg}</p>
))}
<div ref={bottomRef} />
</div>
<button onClick={addMessage}>Add message</button>
</div>
)
}

Let's try it: scrolling a chat UI

Create a chat UI that automatically scrolls to the bottom each time a message is added.

Hint
  1. Place an empty div at the bottom of the message list
  2. Set a ref on that element
  3. Call scrollIntoView() when a message is added
  4. Use behavior: 'smooth' to scroll smoothly
Answer and explanation
import { useState, useRef, useEffect } from 'react'
import './App.css'

function ChatUI() {
const [messages, setMessages] = useState<string[]>([])
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim()) {
setMessages(prev => [...prev, input])
setInput('')
}
}

return (
<div>
<div style={{ height: '300px', overflow: 'auto', border: '1px solid #ccc' }}>
{messages.map((msg, index) => (
<div key={index} style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
{msg}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Enter a message"
/>
<button type="submit">Send</button>
</form>
</div>
)
}

export default function App() {
return <ChatUI />
}

We place an empty div at the end of the message list and scroll with scrollIntoView(). behavior: 'smooth' produces a smooth scroll animation.

Holding the previous value

function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined)

useEffect(() => {
ref.current = value
}, [value])

return ref.current
}

// Usage
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)

const handleIncrement = () => {
setCount(prev => prev + 1)
}

return (
<div>
<p>Current: {count}, Previous: {prevCount}</p>
<button onClick={handleIncrement}>+1</button>
</div>
)
}

Let's try it: display the previous value

Create a counter that displays both the current value and the previous value.

Hint
  1. Create a usePrevious custom hook
  2. Combine useRef and useEffect to hold the previous value
  3. Leverage the fact that useEffect runs after rendering
Answer and explanation
import { useState, useRef, useEffect } from 'react'
import './App.css'

function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined)

useEffect(() => {
ref.current = value
}, [value])

return ref.current
}

function CounterWithPrevious() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)

const handleIncrement = () => {
setCount(prev => prev + 1)
}

const handleDecrement = () => {
setCount(prev => prev - 1)
}

const handleReset = () => {
setCount(0)
}

return (
<div>
<p>Current value: {count}</p>
<p>Previous value: {prevCount ?? '(none)'}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
<button onClick={handleReset}>Reset</button>
</div>
)
}

export default function App() {
return <CounterWithPrevious />
}

The usePrevious custom hook leverages the fact that useEffect runs after rendering. On the first render, ref.current is undefined, and after useEffect runs, the current value is saved. On the next render, the previously saved value is returned.

Holding a timer ID

function Timer() {
const [seconds, setSeconds] = useState(0)
const [isRunning, setIsRunning] = useState(false)
// Hold the timer ID with useRef (hold the value without triggering a re-render)
// number | null is the type of setInterval's return value
const intervalRef = useRef<number | null>(null)

const start = () => {
if (!isRunning) {
setIsRunning(true)
// Save setInterval's ID in ref.current
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000)
}
}

const stop = () => {
if (intervalRef.current) {
// Call clearInterval with the saved ID
clearInterval(intervalRef.current)
intervalRef.current = null
}
setIsRunning(false)
}

const reset = () => {
stop()
setSeconds(0)
}

// Cleanup: stop the timer when the component unmounts
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [])

return (
<div>
<p>{seconds}s</p>
<button onClick={start} disabled={isRunning}>Start</button>
<button onClick={stop} disabled={!isRunning}>Stop</button>
<button onClick={reset}>Reset</button>
</div>
)
}

Forwarding a ref to a child component

This is the pattern for when you want to access a child component's DOM element from the parent. From React 19, it has become much simpler.

From React 19, a function component can receive ref just like a regular Prop. You don't need to wrap it with forwardRef.

import { useRef } from 'react'

type CustomInputProps = {
placeholder?: string
ref?: React.Ref<HTMLInputElement>
}

// Just receive ref as a regular prop
function CustomInput({ ref, ...props }: CustomInputProps) {
return <input ref={ref} className="custom-input" {...props} />
}

// Use it in the parent component
function Form() {
const inputRef = useRef<HTMLInputElement>(null)

const handleClick = () => {
inputRef.current?.focus()
}

return (
<div>
<CustomInput ref={inputRef} placeholder="Enter text" />
<button onClick={handleClick}>Focus</button>
</div>
)
}

forwardRef (React 18 and earlier / for compatibility)

Since you'll encounter forwardRef in React 18 and below or in existing code, we note it here for reference.

import { forwardRef, useRef } from 'react'

// forwardRef: forwards the ref received from the parent to the child's DOM element
// forwardRef<the DOM element type, the Props type>(the component function)
const CustomInput = forwardRef<HTMLInputElement, { placeholder?: string }>(
// 1st argument: props, 2nd argument: the ref forwarded from the parent
(props, ref) => {
return (
<input
ref={ref} // associate the ref passed from the parent with the input element
className="custom-input"
{...props}
/>
)
}
)
info

When using forwardRef, setting a displayName on the component makes debugging in React DevTools easier.

From React 19, forwardRef is not deprecated, but it has become unnecessary. In new code, use the simple prop-receiving form, and there's no need to rewrite existing forwardRef. Gradual migration is enough. Note that the React team has announced plans to deprecate and remove forwardRef in a future version.

A practical example: a custom button

type ButtonProps = {
children: React.ReactNode
variant?: 'primary' | 'secondary'
ref?: React.Ref<HTMLButtonElement>
} & React.ButtonHTMLAttributes<HTMLButtonElement>

function Button({ children, variant = 'primary', className, ref, ...props }: ButtonProps) {
const baseClasses = 'px-4 py-2 rounded font-medium'
const variantClasses = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
}

return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${className ?? ''}`}
{...props}
>
{children}
</button>
)
}

ref callback and cleanup function (React 19+)

The ref attribute can take not only a ref object but also a function (a ref callback). It's called with the element as an argument at the moment the element is added to the DOM. Furthermore, from React 19, a ref callback can return a cleanup function.

<div
ref={node => {
// Called when the element is added to the DOM
console.log('attached:', node)

// Returning a cleanup function calls it when the element is removed from the DOM (on detach) (React 19 and later)
return () => {
console.log('detached')
}
}}
/>

useImperativeHandle

Using useImperativeHandle, you can customize the value of the ref exposed to the parent. This lets you expose only specific methods rather than the entire DOM element.

import { useRef, useImperativeHandle } from 'react'

// Type definition of the exposed methods (not the DOM element itself, but only specific methods)
type CustomInputHandle = {
focus: () => void
clear: () => void
getValue: () => string
}

// Receive ref as a prop of type CustomInputHandle
function CustomInput({ label, ref }: { label: string; ref?: React.Ref<CustomInputHandle> }) {
// Hold an internal reference to the DOM element
const inputRef = useRef<HTMLInputElement>(null)

// useImperativeHandle: customize the methods exposed to the parent
// 1st argument: the ref received from the parent
// 2nd argument: a function that returns the object to expose
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus()
},
clear: () => {
if (inputRef.current) {
inputRef.current.value = ''
}
},
getValue: () => {
return inputRef.current?.value ?? ''
}
}))

return (
<div>
<label>{label}</label>
<input ref={inputRef} type="text" />
</div>
)
}

// Parent component
function Form() {
// Create a ref of type CustomInputHandle (custom methods, not a DOM element)
const inputRef = useRef<CustomInputHandle>(null)

const handleFocus = () => {
// Call the method exposed by the child component
inputRef.current?.focus()
}

const handleSubmit = () => {
const value = inputRef.current?.getValue()
console.log('Input value:', value)
inputRef.current?.clear()
}

return (
<div>
<CustomInput ref={inputRef} label="Name" />
<button onClick={handleFocus}>Focus</button>
<button onClick={handleSubmit}>Send</button>
</div>
)
}

Use cases

useImperativeHandle is useful in the following cases.

  1. When you don't want to expose the entire DOM: From a security and safety standpoint, allow only specific operations
  2. An abstracted API: Hide the details of DOM manipulation and provide easy-to-understand methods
  3. Complex components: Provide a unified interface for a component that has multiple DOM elements
warning

Limit the use of useImperativeHandle to when it's necessary. In most cases, Props and callbacks are enough. Imperative code via ref departs from React's declarative paradigm, so avoid overusing it.

When to use ref

Appropriate use cases

  • Controlling focus, text selection, and media playback
  • Triggering animations
  • Integrating with third-party DOM libraries
  • Holding values that don't affect rendering, such as the previous value or a timer ID

Cases to avoid

  • Problems that can be solved declaratively (when State is enough)
  • Holding data needed for rendering (you should use State)
  • When you frequently read and write ref.current
// NG: trying to control the UI with ref
function BadExample() {
const divRef = useRef<HTMLDivElement>(null)

const handleClick = () => {
if (divRef.current) {
divRef.current.style.display = 'none' // imperative
}
}

return <div ref={divRef}>...</div>
}

// OK: control declaratively with State
function GoodExample() {
const [isVisible, setIsVisible] = useState(true)

return isVisible ? <div>...</div> : null
}

Summary

  • useRef holds a value without triggering a re-render
  • Use it to get a reference to a DOM element
  • Use it to hold values that don't affect the UI, such as a timer ID or the previous value
  • Receive ref as a prop to expose a child component's DOM element to the parent (forwardRef is the React 18-and-earlier method)
  • Customize the exposed methods with useImperativeHandle
  • Limit the use of ref to when it's necessary, and prefer a declarative approach whenever possible

In the next chapter, you learn about global state sharing with useContext.