React: useRef and DOM Manipulation
Last updated: 2026-08-26
useRefIt’s like a safe—you can store anything inside, and it will still be there when you take it out. UnlikeuseState, changingref’s value will not trigger the component to re-render.
1. What You'll Learn
- Basic Usage of useRef (DOM References)
- useRef stores mutable values (without triggering a re-render)
- forwardRef: Passes a ref to a child component
- useImperativeHandle exposes child component methods
- Choosing Between useRef and useState
2. The Story of a Search Bar
The Two Main Uses of useRef
flowchart LR
subgraph "Uses1: DOMQuote"
A[useRef] --> B["ref={inputRef}"]
B --> C["inputRef.current.focus()"]
end
subgraph "Uses2: Variable-Value Storage"
D[useRef] --> E[".current = value]
E --> F[Does not trigger a repaint]
end
G[forwardRef] --> H[Passed from the parent componentref]
H --> I[Child Component ExposureDOM]
style A fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style G fill:#fff3e0,stroke:#e65100
(1) Pain Point: Focusing on an input field requires manipulating the DOM
Bob created a search page and wanted the page to automatically focus on the search box when it loads:
function SearchPage() {
// ❌ Question:There is no direct way to focus on the input field.
return (
<div>
<input type="text" placeholder="Search..." />
{/* How can I make this input field automatically receive focus?? */}
{/* Cannot be used document.getElementById,React Direct manipulation is not recommended. DOM */}
</div>
)
}
Bob wants to use useState to implement:
// ❌ Error:useState Updates trigger a re-render,But I don't know how to use it DOM
function SearchPage() {
const [input, setInput] = React.useState(null)
React.useEffect(() => {
// Hope input is DOM Element...
// But how do you DOM Element Storage state in ?
}, [])
return <input ref={el => setInput(el)} /> // Function ref,The grammar is strange
}
(2) A Solution Using useRef
import { useRef, useEffect } from 'react'
function SearchPage() {
const inputRef = useRef(null) // Create ref
useEffect(() => {
// ✅ Automatically focus on the component after it is mounted
inputRef.current.focus()
}, [])
return <input ref={inputRef} type="text" placeholder="Autofocus search box..." />
}
Benefits: useRef returns a mutable object { current: null }, and .current is a real DOM element. No selectors or getElementById are needed—it’s naturally React-friendly.
3. Two Core Uses of useRef
| Purpose | ref.current points to | Triggers a render update | Typical scenarios |
|---|---|---|---|
| DOM Reference | DOM Element | ❌ | Focus, Scrolling, Measuring Dimensions |
| Variable Storage | Any JavaScript value | ❌ | Timer ID, previous value, non-rendering state |
(1) Use Case 1: DOM References
The most common uses—retrieving DOM elements, manipulating focus, size, scroll position, and so on.
| Operation | Code | Description |
|---|---|---|
| Focus | inputRef.current.focus() |
Autofocus Input Field |
| Select Text | inputRef.current.select() |
Select All in Input Field |
| Scroll to | divRef.current.scrollIntoView() |
Scroll to a specific element |
| Read Dimensions | divRef.current.offsetHeight |
Get Element Height |
| Play Video | videoRef.current.play() |
Control Video Playback |
▶ Example: 5 Common DOM Operations
Output:
Buttons: Select All Text, Scroll to the bottom, Measure the height. Input: Autofocus input field. side effects via useEffect. DOM ref via useRef
// ============================================
// Example:useRef 's 5 Common Types DOM Operation
// ============================================
function DomOperations() {
const inputRef = useRef(null)
const videoRef = useRef(null)
const listRef = useRef(null)
const boxRef = useRef(null)
const [boxHeight, setBoxHeight] = React.useState(0)
// 1. Autofocus
useEffect(() => { inputRef.current.focus() }, [])
function handleSelect() { inputRef.current.select() } // 2. Select All
function handlePlay() { videoRef.current.play() } // 3. Play
function handlePause() { videoRef.current.pause() } // 3. Pause
function handleScroll() { listRef.current.scrollIntoView({ behavior: 'smooth' }) } // 4. Scroll
function handleMeasure() { // 5. Measure Dimensions
setBoxHeight(boxRef.current.offsetHeight)
}
return (
<div>
<h3>DOM Example of Operation</h3>
<input ref={inputRef} placeholder="Autofocus input field" />
<button onClick={handleSelect}>Select All Text</button>
<div ref={listRef} style={{ height: '100px', overflow: 'auto', border: '1px solid #ddd', margin: '10px 0' }}>
{Array.from({ length: 20 }, (_, i) => <p key={i}>Row {i + 1}</p>)}
</div>
<button onClick={handleScroll}>Scroll to the bottom</button>
<div ref={boxRef} style={{ padding: '20px', backgroundColor: '#f0f0f0', margin: '10px 0' }}>
<p>The height of this element is:{boxHeight}px</p>
<button onClick={handleMeasure}>Measure the height</button>
</div>
</div>
)
}
Output:
Autofocus input, "Select All Text" button, scrollable 20-row list with "Scroll to bottom", box with "Measure the height" → shows px value
(2) Use Case 2: Storing variable values (without triggering a redraw)
This is useRef’s hidden trick—it stores any value, and the component won’t re-render when that value changes.
function Stopwatch() {
const [time, setTime] = React.useState(0)
const timerRef = useRef(null) // Storage Timer ID,Do not participate in the rendering
function start() {
if (timerRef.current) return // Prevent Repeated Starts
timerRef.current = setInterval(() => {
setTime(t => t + 1)
}, 1000)
}
function stop() {
clearInterval(timerRef.current)
timerRef.current = null // Reset
}
return (
<div>
<p>Timer: {time}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
)
}
// timerRef Storage Timer ID
// Change timerRef.current Will not trigger a re-render
// The timer is not automatically cleared when the component is unloaded.(I remember back in useEffect Clean up)
4. useRef vs. useState
| Dimension | useRef | useState |
|---|---|---|
| Return value | { current: initial } |
[value, setter] |
| Modification Method | ref.current = newValue |
setState(newValue) |
| Render after modification | ❌ Does not trigger | ✅ Triggers a re-render |
| When it is read | Reads the latest value immediately | Does not read it until the next render |
| Use Cases | DOM operations, timer IDs, previous values | Data to be displayed in the UI |
function RefVsState() {
const renderCount = useRef(1) // A counter that won't be triggered
const [count, setCount] = useState(0) // Counters that will be triggered
useEffect(() => {
renderCount.current += 1 // Change it however you like,Does not trigger a repaint
})
return (
<div>
<p>useState:{count}(Click to trigger rendering)</p>
<p>useRef:{renderCount.current}(View only in the console)</p>
<button onClick={() => setCount(c => c + 1)}>useState +1</button>
<button onClick={() => renderCount.current += 1}>useRef +1(No change)</button>
</div>
)
}
▶ Example: Using useRef to save the previous value
Output:
Displays: "DOM Example of Operation". Buttons: Select All Text, Scroll to the bottom, Measure the height. Input: Autofocus input field. useEffect manages side effects. useRef references DOM/variable
// ============================================
// Example:Go back to props/state value
// ============================================
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value // Update after each render
})
return ref.current // Go back to the previous value
}
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count) // The previous value
return (
<div>
<p>Currently:{count}</p>
<p>Last time:{prevCount !== undefined ? prevCount : '(For the first time)'}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
// Process:
// 1. Initial Rendering:count=0, prevCount=undefined
// 2. Click +1 → count=1
// 3. useEffect Update prev=0 → Back 0
// 4. Click again +1 → count=2, prev=1
Output:
Currently: 0, Last time: (first time). Click +1 → Currently: 1, Last time: 0. Each click shows current and previous value.
5. forwardRef: Parent Components Control the DOM of Child Components
By default, function components do not expose their own DOM references. Using forwardRef allows child components to receive the parent component's ref:
// ---- Child component:use forwardRef Package ----
const CustomInput = forwardRef(function CustomInput(props, ref) {
return (
<div style={{ border: '1px solid #d9d9d9', padding: '4px', borderRadius: '4px' }}>
<input ref={ref} {...props} style={{ border: 'none', outline: 'none', width: '100%' }} />
</div>
)
})
// ---- Parent Component:Use directly ref Controlling subcomponents input ----
function Form() {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current.focus() // ✅ Can focus directly on CustomInput Internal input
}, [])
return (
<div>
<CustomInput ref={inputRef} placeholder="Input fields controlled by the parent component" />
<button onClick={() => inputRef.current.focus()}>In Focus</button>
<button onClick={() => inputRef.current.select()}>Select All</button>
</div>
)
}
6. useImperativeHandle: Expose a specific method
If you don't want to expose the entire DOM element, use useImperativeHandle to expose only specific methods:
// ============================================
// Example:Custom Player——Expose only play/pause,Without revealing the whole video
// ============================================
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null)
// Expose only 3 A method for the parent component
useImperativeHandle(ref, () => ({
play() {
videoRef.current.play()
},
pause() {
videoRef.current.pause()
},
jumpTo(seconds) {
videoRef.current.currentTime = seconds
}
}))
return <video ref={videoRef} src={src} controls style={{ width: '100%' }} />
})
// ---- Usage -----
function App() {
const playerRef = useRef(null)
return (
<div>
<h3>Video Player</h3>
<VideoPlayer ref={playerRef} src="https://example.com/video.mp4" />
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
<button onClick={() => playerRef.current.play()}>▶ Play</button>
<button onClick={() => playerRef.current.pause()}>⏸ Pause</button>
<button onClick={() => playerRef.current.jumpTo(30)}>⏭ Jump to 30s</button>
</div>
{/* The parent component cannot be manipulated directly video DOM,You can only call the exposed play/pause/jumpTo */}
</div>
)
}
7. Complete Example: Rich Text Editor
// ============================================
// Complete Example:Simple Rich Text Editor
// Features:useRef Control contentEditable Region
// forwardRef + useImperativeHandle Exposure Methods
// ============================================
const RichEditor = forwardRef(function RichEditor({ placeholder }, ref) {
const editorRef = useRef(null)
const [isEmpty, setIsEmpty] = React.useState(true)
// Exposing Methods to the Parent Component
useImperativeHandle(ref, () => ({
getContent() {
return editorRef.current.innerHTML
},
setContent(html) {
editorRef.current.innerHTML = html
checkEmpty()
},
clear() {
editorRef.current.innerHTML = ''
setIsEmpty(true)
editorRef.current.focus()
},
focus() {
editorRef.current.focus()
}
}))
function checkEmpty() {
const text = editorRef.current.textContent || ''
setIsEmpty(text.trim().length === 0)
}
function handleKeyDown(e) {
if (e.ctrlKey && e.key === 'b') {
document.execCommand('bold')
e.preventDefault()
}
if (e.ctrlKey && e.key === 'i') {
document.execCommand('italic')
e.preventDefault()
}
}
return (
<div style={{ border: '1px solid #d9d9d9', borderRadius: '4px', overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #d9d9d9', backgroundColor: '#fafafa', display: 'flex', gap: '4px' }}>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('bold') }} style={toolBtnStyle}><b>B</b></button>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('italic') }} style={toolBtnStyle}><i>I</i></button>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('underline') }} style={toolBtnStyle}><u>U</u></button>
<span style={{ color: '#ddd' }}>|</span>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('insertUnorderedList') }} style={toolBtnStyle}>List</button>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('formatBlock', false, 'h2') }} style={toolBtnStyle}>H2</button>
</div>
{/* Edit Area */}
<div
ref={editorRef}
contentEditable
onInput={checkEmpty}
onKeyDown={handleKeyDown}
style={{
minHeight: '200px',
padding: '16px',
outline: 'none',
lineHeight: '1.6'
}}
data-placeholder={placeholder}
{...(isEmpty ? { 'data-empty': 'true' } : {})}
/>
</div>
)
})
function App() {
const editorRef = useRef(null)
const [savedContent, setSavedContent] = React.useState('')
function handleSave() {
const content = editorRef.current.getContent()
setSavedContent(content)
alert('Saved!')
}
function handleClear() {
editorRef.current.clear()
}
function handleLoad() {
editorRef.current.setContent('<h2>Loaded content</h2><p>This is loaded from an external source. HTML。</p>')
}
return (
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
<h2>📝 Rich Text Editor</h2>
<RichEditor ref={editorRef} placeholder="Start Writing..." />
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
<button onClick={handleSave} style={btnStyle('#1890ff')}>💾 Save</button>
<button onClick={handleLoad} style={btnStyle('#52c41a')}>📂 Loading Example</button>
<button onClick={handleClear} style={btnStyle('#ff4d4f')}>🗑 Clear</button>
</div>
{savedContent && (
<div style={{ marginTop: '16px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ fontWeight: 'bold', margin: '0 0 8px 0' }}>Saved Content:</p>
<div style={{ fontSize: '13px', color: '#666', wordBreak: 'break-all', fontFamily: 'monospace' }}>
{savedContent}
</div>
</div>
)}
</div>
)
}
const toolBtnStyle = {
padding: '4px 10px', border: '1px solid transparent',
borderRadius: '3px', backgroundColor: 'transparent',
cursor: 'pointer', fontSize: '14px'
}
const btnStyle = (color) => ({
padding: '8px 16px', backgroundColor: color,
color: 'white', border: 'none',
borderRadius: '4px', cursor: 'pointer'
})
Expected Output: A rich-text editor that supports bold, italics, underline, lists, H2 headings, and other formatting options; content can be saved, loaded, or cleared externally via a
ref.
▶ Example 3: Using useRef to Close a Pop-up Window When Clicked from Outside
Output:
State: count (setter: setCount). Button: setCount(c => c + 1)}>+1. useEffect manages side effects. useRef references DOM/variable
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [callback])
return ref
}
function Dropdown() {
const [open, setOpen] = useState(false)
const dropdownRef = useClickOutside(() => setOpen(false))
return (
<div ref={dropdownRef} style={{ position: 'relative', display: 'inline-block' }}>
<button onClick={() => setOpen(!open)} style={{ padding: '8px 16px', cursor: 'pointer' }}>
Menu ▾
</button>
{open && (
<div style={{ position: 'absolute', top: '100%', left: 0, background: 'white', border: '1px solid #ddd', borderRadius: 4, minWidth: 120, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
{['Profile', 'Settings', 'Logout'].map(item => (
<div key={item} onClick={() => { setOpen(false) }}
style={{ padding: '8px 16px', cursor: 'pointer' }}>
{item}
</div>
))}
</div>
)}
</div>
)
}
Output:
"Menu ▾" button → dropdown (Profile, Settings, Logout). Click outside → closes automatically via useClickOutside hook.
▶ Example 4: Using useRef to Store a Timer for Implementing a Throttled Button
Output:
Throttled button: rapid clicks ignored (1 per second max). Click counter + "Throttled!" feedback. useRef stores lastClick timestamp
function ThrottledButton() {
const lastClickRef = useRef(0)
const [clicks, setClicks] = useState(0)
const [feedback, setFeedback] = useState('')
function handleClick() {
const now = Date.now()
if (now - lastClickRef.current < 1000) {
setFeedback('Too fast! Wait 1 second.')
return
}
lastClickRef.current = now
setClicks(c => c + 1)
setFeedback('Clicked!')
setTimeout(() => setFeedback(''), 500)
}
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<button onClick={handleClick}
style={{ padding: '10px 24px', fontSize: 16, cursor: 'pointer' }}>
Click Me
</button>
<p>Clicks: {clicks}</p>
{feedback && <p style={{ color: feedback.includes('Too') ? '#ff4d4f' : '#52c41a' }}>{feedback}</p>}
</div>
)
}
Output:
Click → "Clicked!" + count. Click again within 1s → "Too fast! Wait 1 second." (red). Throttled via useRef timestamp.
▶ Example 5: Using forwardRef to wrap a focusable Input component
Output:
Displays: "Click Me". State: clicks (setter: setClicks), feedback (setter: setFeedback). Button: Click Me. useRef references DOM/variable. Timer-based behavior
const FancyInput = React.forwardRef(function FancyInput({ label, error, ...props }, ref) {
const internalRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => internalRef.current?.focus(),
selectAll: () => {
const el = internalRef.current
if (el) { el.focus(); el.select() }
},
scrollIntoView: () => internalRef.current?.scrollIntoView({ behavior: 'smooth' }),
}))
return (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>{label}</label>
<input
ref={internalRef}
style={{ width: '100%', padding: 8, borderRadius: 4, border: `1px solid ${error ? '#ff4d4f' : '#d9d9d9'}`, outline: 'none' }}
{...props}
/>
{error && <p style={{ color: '#ff4d4f', fontSize: 12, margin: '4px 0 0' }}>{error}</p>}
</div>
)
})
function FormWithFancyInput() {
const emailRef = useRef(null)
function handleSubmit(e) {
e.preventDefault()
emailRef.current?.focus()
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '0 auto' }}>
<h3>Sign Up</h3>
<FancyInput ref={emailRef} label="Email" type="email" placeholder="you@example.com" />
<FancyInput label="Password" type="password" placeholder="At least 6 characters" />
<button type="submit" style={{ padding: '8px 24px', cursor: 'pointer' }}>Submit</button>
</form>
)
}
Output:
Sign up form: labeled Email & Password inputs with error styling. Submit focuses email. Exposes focus(), selectAll(), scrollIntoView().
❓ FAQ
useRef replace useState?useRef do not trigger a component re-render, whereas setState does. If your value needs to be displayed in the UI or affect the rendering result, you must use useState. If you’re simply storing “data that doesn’t need to be shown to the user” (such as an interval ID, the value from the previous render, or a WebSocket instance), use useRef. Think of useRef as a “variable container that doesn’t trigger a re-render.”📖 Summary
useRefreturns to{ current: initialValue }; changing.currentdoes not trigger a re-render- Two uses: ① DOM references (focus, size, scroll) ② Storing variable values (timer ID, previous value)
forwardRef: Allowing a parent component to control a child component's DOM via a refuseImperativeHandle: A method for limiting exposure by providing only the APIs needed by the parent component- Always check for null before operating on ref:
ref.current?.focus()
📝 Exercises
- Basic Exercise (Difficulty ⭐): Create a
AutoFocusInputcomponent that automatically receives focus when mounted, and provide two methods—focus()andclear()—for the parent component to call. - Advanced Exercise (Difficulty ⭐⭐): Create a
ClickCountercomponent, useuseRefto track the number of clicks (without displaying it in the UI), and useuseEffectto log "Clicked X times" to the console on every render. - Challenge (Difficulty: ⭐⭐⭐): Create a
InfiniteScrollcomponent that usesuseRefto listen for the sentinel element (a specific element at the bottom of the page) entering the viewport, triggering the loading of more data. Use theIntersectionObserverAPI.