🎯 useRef Hook
Welcome to one of React's most versatile hooks! You already know useState for values that should trigger a re-render when they change. But what about a value that needs to survive from one render to the next without repainting the screen every time it changes? Or the moment when you need to reach past React and touch a real DOM element directly — to focus an input, measure a box, or hand a <canvas> to a charting library? That is exactly the gap useRef fills. Think of a ref as a little box bolted to the wall of your component: renders come and go, the furniture gets rearranged, but the box stays put and keeps whatever you left inside it. By the end of this lesson you'll reach for refs with confidence — and, just as importantly, you'll know when not to. 🔮
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a ref is and the two problems it solves
- Access DOM elements directly and safely with
useRef - Store mutable values that persist across renders without re-rendering
- Choose correctly between a ref and state, and explain why
- Type refs precisely with TypeScript 5.x (including the new required initial argument)
- Implement everyday ref patterns: focus, scroll, measurement, previous-value
- Pass refs to child components the modern React 19 way (ref as a normal prop)
- Expose a controlled imperative API with
useImperativeHandle - Avoid the common ref mistakes and anti-patterns that trip up beginners
Estimated Time: 60-75 minutes
Project: Build focus management, auto-scroll, and media controls
In This Lesson
🎯 Introduction to Refs
Before we wire up a single useRef call, let's slow down and build a solid mental model of what a ref actually is — because almost every ref bug people hit comes from misunderstanding this one idea.
📖 Definition
Ref (short for "reference"): a plain JavaScript object with a single property, current, that you can freely read and write. The key difference from state: writing to ref.current does not schedule a re-render. A ref quietly holds a value off to the side, outside React's rendering cycle.
Why Does React Even Need Refs?
React is declarative. You describe what the UI should look like for a given set of props and state, and React figures out the DOM operations to make that happen. You rarely touch the DOM yourself — and that's a feature, not a limitation. But every now and then you genuinely need to step outside that declarative flow: to imperatively call .focus() on an input, read an element's measured height, or pause a <video>. The DOM is imperative by nature, and some browser APIs and third-party libraries simply expect a real element handle. Refs are React's official, blessed escape hatch for exactly these moments — a controlled door out of declarative-land, not a hole in the wall.
✅ Use Refs When You Need To:
- Access DOM elements - Focus inputs, measure sizes, scroll positions
- Store mutable values - Values that don't affect rendering (timers, IDs)
- Integrate with non-React code - Third-party libraries, animations
- Avoid re-renders - Store values without triggering updates
- Track previous values - Remember the last state or props
- Imperatively control things - Play/pause video, trigger animations
⚠️ Don't Use Refs For:
- Rendering data - Use state for that
- Triggering re-renders - That's what setState is for
- Replacing state - State is better when UI depends on the value
- Storing derived values - Calculate them during render instead
The Two Main Use Cases
| Use Case | Example | Why Ref? |
|---|---|---|
| DOM Access | Focus an input, scroll to element | Need direct access to DOM node |
| Mutable Values | Store timer ID, previous value | Value changes, but doesn't affect UI |
Real-World Scenarios
When Refs Solve Real Problems
- 🎥 Video Player - Control playback imperatively
- 📝 Form Focus - Focus first invalid field on submit
- 📜 Infinite Scroll - Detect when user scrolls to bottom
- 🎨 Canvas Drawing - Get canvas context for drawing
- ⏱️ Timers - Store interval/timeout IDs for cleanup
- 📏 Measurements - Get element width/height
- 🔊 Audio Control - Play/pause audio programmatically
- 📸 Camera Access - Control camera stream
🔍 Understanding useRef
Let's look under the hood. Once you see what useRef actually returns and how React treats it, every pattern later in this lesson becomes obvious rather than magical.
⚠️ React 19 / TypeScript 5.x: the initial argument is now required
In older React type definitions you could write useRef<HTMLInputElement>() with empty parentheses. As of React 19, useRef always requires an initial value — even if that value is null or undefined. So write useRef<HTMLInputElement>(null) for DOM refs and useRef<T | undefined>(undefined) for a value that genuinely starts empty. Every example in this lesson follows the new rule.
The Basic Syntax
import { useRef } from 'react';
function MyComponent() {
// Create a ref
const myRef = useRef(initialValue);
// Access the current value
console.log(myRef.current); // initialValue
// Modify the current value
myRef.current = newValue;
return <div>Hello</div>;
}
What useRef Returns
useRef returns an object with a single property:
// This is what useRef returns:
{
current: yourInitialValue
}
// You can think of it like this simple object:
const ref = { current: 0 };
ref.current = 5; // You can change it
console.log(ref.current); // 5
💡 Key Insight
The ref object itself NEVER changes - it's the same object on every render. Only ref.current can be modified. This is why refs don't trigger re-renders - React doesn't track changes to the current property!
Refs Persist Across Renders
Unlike regular variables, refs maintain their value across component re-renders:
function Counter() {
// Regular variable - resets to 0 on every render
let count = 0;
// Ref - persists across renders
const countRef = useRef(0);
const handleClick = () => {
count++; // This resets on next render!
countRef.current++; // This persists!
console.log('Regular:', count); // Always 1
console.log('Ref:', countRef.current); // 1, 2, 3, 4...
};
return <button onClick={handleClick}>Click me</button>;
}
The Ref Lifecycle
between renders
Comparing Ref to State
Let's see the key differences side by side:
function Example() {
// State - triggers re-render when changed
const [count, setCount] = useState(0);
// Ref - does NOT trigger re-render when changed
const countRef = useRef(0);
const handleStateClick = () => {
setCount(count + 1); // Component re-renders
console.log('State updated:', count); // Still shows old value!
};
const handleRefClick = () => {
countRef.current++; // No re-render
console.log('Ref updated:', countRef.current); // Shows new value immediately
};
return (
<div>
<p>State: {count}</p>
<p>Ref: {countRef.current}</p> {/* Won't update on screen! */}
<button onClick={handleStateClick}>Update State</button>
<button onClick={handleRefClick}>Update Ref</button>
</div>
);
}
| Feature | useState | useRef |
|---|---|---|
| Triggers Re-render | ✅ Yes | ❌ No |
| Value Persists | ✅ Yes | ✅ Yes |
| Mutable | ❌ No (use setState) | ✅ Yes (change .current) |
| Synchronous | ❌ No (batched updates) | ✅ Yes (immediate) |
| Use for Rendering | ✅ Yes | ❌ No |
| Use for DOM Access | ❌ No | ✅ Yes |
⚠️ Important Rule
Never read or write ref.current during rendering! Refs are meant for side effects (event handlers, useEffect), not rendering logic.
// ❌ Bad - reading ref during render
function BadComponent() {
const countRef = useRef(0);
return <div>{countRef.current}</div>; // Don't do this!
}
// ✅ Good - using ref in event handler
function GoodComponent() {
const countRef = useRef(0);
const handleClick = () => {
console.log(countRef.current); // Perfect!
};
return <button onClick={handleClick}>Click</button>;
}
🎯 Accessing DOM Elements
This is the use case most people meet first: you have a real element on the page — an input, a video, a scrollable list — and you need to do something to it that JSX can't express. You can't write "focus this input" declaratively; focusing is an action, a verb. Refs give you a handle to the underlying DOM node so you can call those verbs directly. The pattern is always the same three steps: create a ref, attach it to an element with the ref attribute, then reach for ref.current in an event handler or effect once the element exists.
Basic DOM Ref Example
To attach a ref to a DOM element, pass it to the ref attribute:
import { useRef } from 'react';
function FocusInput() {
// Create a ref for the input element
const inputRef = useRef<HTMLInputElement>(null);
const handleFocus = () => {
// Access the DOM element via ref.current
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>Focus Input</button>
</div>
);
}
💡 How It Works
- Create a ref with
useRef<HTMLInputElement>(null) - React sets
inputRef.currentto the DOM node when it mounts - You can access the DOM node via
inputRef.current - React sets
inputRef.currentback to null when it unmounts
The ref Lifecycle with DOM
ref.current points to DOM React->>Ref: Set ref.current = null React->>DOM: Remove DOM element Note over Ref: Component unmounted
ref.current is null again
Common DOM Operations
Here are the most common things you'll do with DOM refs:
1. Focus Management
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Focus first empty field
if (!emailRef.current?.value) {
emailRef.current?.focus();
} else if (!passwordRef.current?.value) {
passwordRef.current?.focus();
}
};
// Focus email on mount
useEffect(() => {
emailRef.current?.focus();
}, []);
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} type="email" placeholder="Email" />
<input ref={passwordRef} type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
);
}
2. Scrolling
function ChatRoom() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
// Scroll to bottom when new message arrives
useEffect(() => {
scrollToBottom();
}, [messages]);
return (
<div className="chat-container">
{messages.map(msg => (
<div key={msg.id}>{msg.text}</div>
))}
<div ref={messagesEndRef} /> {/* Invisible anchor */}
</div>
);
}
3. Measuring Elements
function MeasureBox() {
const boxRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const measureBox = () => {
if (boxRef.current) {
const { width, height } = boxRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
};
useEffect(() => {
measureBox();
window.addEventListener('resize', measureBox);
return () => window.removeEventListener('resize', measureBox);
}, []);
return (
<div>
<div ref={boxRef} className="box">
Resize the window!
</div>
<p>Width: {dimensions.width}px</p>
<p>Height: {dimensions.height}px</p>
</div>
);
}
4. Video/Audio Control
function VideoPlayer() {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
return (
<div>
<video ref={videoRef} src="video.mp4" />
<button onClick={togglePlay}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
);
}
✅ DOM Ref Best Practices
- Always use optional chaining (
?.) when accessingref.current - Check that
ref.currentexists before using it (it isnullbefore mount and after unmount) - Initialize DOM refs with
null - Type your refs with the correct HTML element type
- Clean up event listeners and timers in a
useEffectreturn function
⚖️ Refs vs State
Understanding when to use refs versus state is crucial for writing good React code. Let's explore the differences in depth and learn when to use each.
The Fundamental Difference
function ComparisonDemo() {
const [stateCount, setStateCount] = useState(0);
const refCount = useRef(0);
console.log('Component rendered');
return (
<div>
{/* State - displays on screen, triggers re-render */}
<p>State Count: {stateCount}</p>
<button onClick={() => setStateCount(stateCount + 1)}>
Increment State (re-renders)
</button>
{/* Ref - won't update on screen, no re-render */}
<p>Ref Count: {refCount.current}</p>
<button onClick={() => refCount.current++}>
Increment Ref (no re-render)
</button>
</div>
);
// Click state button: "Component rendered" logs
// Click ref button: Nothing logs!
}
Decision Tree: When to Use What?
Side-by-Side Examples
Example 1: Click Counter (Use State)
// ✅ Use state - user needs to see the count
function ClickCounter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Clicks: {count}</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Example 2: Render Counter (Use Ref)
// ✅ Use ref - tracking renders, not displaying to user
function RenderCounter() {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
console.log(`Rendered ${renderCount.current} times`);
});
const [name, setName] = useState('');
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
{/* Don't try to display ref.current in JSX! */}
</div>
);
}
Example 3: Timer ID (Use Ref)
// ✅ Use ref - timer ID doesn't affect UI
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef<number | null>(null);
const startTimer = () => {
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
useEffect(() => {
return () => stopTimer(); // Cleanup
}, []);
return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}
Common Patterns Compared
| Scenario | Use State | Use Ref |
|---|---|---|
| Form input value | ✅ Yes - display in UI | ❌ No |
| Toggle button state | ✅ Yes - affects UI | ❌ No |
| Modal open/closed | ✅ Yes - conditionally render | ❌ No |
| DOM element reference | ❌ No | ✅ Yes - access DOM |
| Previous props/state | ❌ No | ✅ Yes - track history |
| setInterval/setTimeout ID | ❌ No | ✅ Yes - for cleanup |
| WebSocket connection | ❌ No | ✅ Yes - persist connection |
| Animation frame ID | ❌ No | ✅ Yes - for cancellation |
✅ Quick Decision Guide
Use State when:
- The value is displayed in the UI
- Changes should trigger a re-render
- The component's output depends on the value
Use Ref when:
- You need to access a DOM element
- Value changes but UI doesn't depend on it
- You need to store something without triggering re-renders
The "Previous Value" Pattern
A common use case: tracking the previous value of state or props.
function usePrevious<T>(value: T): T | undefined {
// React 19 requires an initial argument — pass undefined explicitly
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);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
🔷 Typing Refs with TypeScript
Refs are one of the places where TypeScript quietly earns its keep. The type you hand to useRef flows straight into ref.current, so getting it right means the compiler catches the classic "cannot read focus of null" crash before you ever run the code. The whole game comes down to one question: can this ref legitimately be empty? DOM refs start out empty (the element doesn't exist until React mounts it), so their type includes null. Value refs that always hold something can be typed as exactly that something. Let's make both cases second nature.
Basic Ref Typing
// Generic syntax: useRef<Type>(initialValue)
// DOM element refs - always start with null
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Value refs - can start with any value
const countRef = useRef<number>(0);
const nameRef = useRef<string>('');
const dataRef = useRef<User | null>(null);
Common HTML Element Types
| Element | TypeScript Type | Example |
|---|---|---|
| <input> | HTMLInputElement |
useRef<HTMLInputElement>(null) |
| <button> | HTMLButtonElement |
useRef<HTMLButtonElement>(null) |
| <div> | HTMLDivElement |
useRef<HTMLDivElement>(null) |
| <form> | HTMLFormElement |
useRef<HTMLFormElement>(null) |
| <video> | HTMLVideoElement |
useRef<HTMLVideoElement>(null) |
| <canvas> | HTMLCanvasElement |
useRef<HTMLCanvasElement>(null) |
| <img> | HTMLImageElement |
useRef<HTMLImageElement>(null) |
| Generic element | HTMLElement |
useRef<HTMLElement>(null) |
Why null for DOM Refs?
// ✅ Correct - DOM refs start as null
const inputRef = useRef<HTMLInputElement>(null);
// TypeScript knows ref.current might be null
inputRef.current?.focus(); // Use optional chaining
// ❌ Wrong - TypeScript error!
const inputRef = useRef<HTMLInputElement>();
// Error: Argument of type 'undefined' is not assignable to parameter of type 'HTMLInputElement | null'
💡 Why null?
DOM elements don't exist until React creates them during render. So refs that hold DOM elements must start as null. React will set ref.current to the actual DOM node after mounting.
Typing Value Refs
// Simple types
const countRef = useRef<number>(0);
const nameRef = useRef<string>('John');
const isActiveRef = useRef<boolean>(false);
// Complex types
interface User {
id: number;
name: string;
}
const userRef = useRef<User | null>(null);
const usersRef = useRef<User[]>([]);
// Timer IDs — the browser and Node return different types.
// ReturnType<typeof setInterval> works in both environments:
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// WebSocket or other APIs
const socketRef = useRef<WebSocket | null>(null);
Optional Chaining with Refs
function SafeRefAccess() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
// ✅ Safe - won't crash if ref.current is null
inputRef.current?.focus();
// ✅ Safe - check with if statement
if (inputRef.current) {
inputRef.current.focus();
}
// ❌ Unsafe - could crash!
// inputRef.current.focus(); // TypeScript error
};
return <input ref={inputRef} />;
}
Mutable vs Immutable Refs
// Mutable ref (default) - you can change .current
const mutableRef = useRef<number>(0);
mutableRef.current = 5; // ✅ Allowed
// Read-only ref (rare, but possible)
const readonlyRef = useRef<number>(0) as { readonly current: number };
// readonlyRef.current = 5; // ❌ TypeScript error
Generic Ref Typing
// Generic function that works with any ref type
function useLatest<T>(value: T) {
const ref = useRef<T>(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
// Usage with different types
const nameLatest = useLatest('John'); // RefObject<string>
const countLatest = useLatest(42); // RefObject<number>
const userLatest = useLatest({ id: 1, name: 'Alice' }); // RefObject<User>
Ref Type Patterns
// Pattern 1: Union type for multiple possibilities
const elementRef = useRef<HTMLDivElement | HTMLSpanElement>(null);
// Pattern 2: Nullable ref
const optionalRef = useRef<string | null>(null);
// Pattern 3: Array of refs
const itemRefs = useRef<HTMLDivElement[]>([]);
// Pattern 4: Ref to custom component (we'll cover this later)
const customRef = useRef<CustomComponentHandle>(null);
✅ TypeScript Ref Best Practices
- Always specify the type parameter:
useRef<Type> - Use
nullas initial value for DOM refs - Use optional chaining (
?.) when accessing DOM refs - Use the most specific HTML element type available
- For union types, use type guards to narrow
📦 Storing Mutable Values
DOM access gets all the attention, but the second job of useRef is just as useful and far more subtle: it's a place to stash a value that must outlive individual renders yet has no business appearing on screen. Think of the things a class component used to keep as instance fields — a timer id, an open socket, a "am I still mounted?" flag, the previous value of a prop. In a function component those can't be plain local variables (they'd reset every render) and they shouldn't be state (changing them shouldn't repaint anything). A ref is the perfect middle ground: persistent like state, silent like a variable. Here are the patterns you'll use again and again.
Pattern 1: Timer IDs
Store timer IDs to clean them up later:
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef<number | null>(null);
const start = () => {
if (!isRunning) {
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(t => t + 10);
}, 10);
}
};
const stop = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
setIsRunning(false);
}
};
const reset = () => {
stop();
setTime(0);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return (
<div>
<p>Time: {(time / 1000).toFixed(2)}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Pattern 2: Previous Values
Track previous state or props:
function ValueChangeDetector({ value }: { value: number }) {
const prevValueRef = useRef<number>(value);
useEffect(() => {
const prev = prevValueRef.current;
if (prev !== value) {
console.log(`Value changed from ${prev} to ${value}`);
}
// Update ref for next render
prevValueRef.current = value;
}, [value]);
return <div>Current value: {value}</div>;
}
Pattern 3: Instance Variables
Store class-like instance variables in functional components:
function DataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
// Track if component is mounted to prevent setState on unmounted component
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch('/api/data');
const result = await response.json();
// Only update state if still mounted
if (isMountedRef.current) {
setData(result);
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
};
return (
<div>
<button onClick={fetchData}>Fetch Data</button>
{loading && <p>Loading...</p>}
</div>
);
}
Pattern 4: Callback Refs
Store the latest version of a callback:
function useLatestCallback<T extends (...args: any[]) => any>(callback: T) {
const callbackRef = useRef(callback);
// Update ref whenever callback changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Return stable callback that always calls the latest version
const stableCallback = useCallback(
(...args: Parameters<T>) => callbackRef.current(...args),
[]
);
return stableCallback;
}
// Usage
function SearchBox({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
// onSearch might change, but we want stable reference for useEffect
const stableOnSearch = useLatestCallback(onSearch);
useEffect(() => {
const timer = setTimeout(() => {
stableOnSearch(query);
}, 500);
return () => clearTimeout(timer);
}, [query]); // Only query in deps, not onSearch!
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Pattern 5: Web APIs
Store references to web APIs:
function WebSocketChat() {
const [messages, setMessages] = useState<string[]>([]);
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
// Create WebSocket connection
socketRef.current = new WebSocket('ws://localhost:8080');
socketRef.current.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
// Cleanup on unmount
return () => {
socketRef.current?.close();
};
}, []);
const sendMessage = (message: string) => {
socketRef.current?.send(message);
};
return (
<div>
{messages.map((msg, i) => (
<p key={i}>{msg}</p>
))}
<button onClick={() => sendMessage('Hello')}>
Send Hello
</button>
</div>
);
}
💡 When to Use Mutable Refs
Use refs for mutable values when:
- The value needs to persist across renders
- Changing it shouldn't trigger a re-render
- You need to store IDs for cleanup (timers, subscriptions)
- You're tracking component mount status
- You need the latest value of a prop/callback in an effect
⚠️ Common Mistake
Don't use refs to avoid re-renders when state is the right choice:
// ❌ Bad - using ref when state is needed
const countRef = useRef(0);
return <p>{countRef.current}</p>; // Won't update!
// ✅ Good - use state for UI
const [count, setCount] = useState(0);
return <p>{count}</p>; // Updates correctly
🎨 Common Ref Patterns
Let's explore the most common and useful patterns for using refs in real applications. These patterns solve everyday problems you'll encounter.
Pattern 1: Focus Management
One of the most common uses of refs is managing focus - automatically focusing inputs, moving focus between fields, or focusing on errors.
interface LoginFormProps {
onSubmit: (credentials: { email: string; password: string }) => void;
}
function LoginForm({ onSubmit }: LoginFormProps) {
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
// Focus email input when component mounts
useEffect(() => {
emailRef.current?.focus();
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const email = emailRef.current?.value || '';
const password = passwordRef.current?.value || '';
// Validate
const newErrors: { email?: string; password?: string } = {};
if (!email.includes('@')) {
newErrors.email = 'Invalid email';
emailRef.current?.focus(); // Focus first error
setErrors(newErrors);
return;
}
if (password.length < 8) {
newErrors.password = 'Password must be 8+ characters';
passwordRef.current?.focus();
setErrors(newErrors);
return;
}
setErrors({});
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
ref={emailRef}
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
ref={passwordRef}
type="password"
aria-invalid={!!errors.password}
/>
{errors.password && (
<span role="alert">{errors.password}</span>
)}
</div>
<button type="submit">Login</button>
</form>
);
}
✅ Focus Management Best Practices
- Focus first input on mount for better UX
- Focus first invalid field on validation error
- Use
aria-invalidandaria-describedbyfor accessibility - Consider keyboard navigation (Tab order)
- Don't steal focus unexpectedly - only on user action or mount
Pattern 2: Scroll Management
Scrolling to specific elements is another common use case - like scrolling to new messages, jumping to sections, or creating "back to top" buttons.
interface Message {
id: string;
text: string;
sender: string;
timestamp: Date;
}
interface ChatMessagesProps {
messages: Message[];
}
function ChatMessages({ messages }: ChatMessagesProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Show/hide scroll-to-bottom button based on scroll position
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
setShowScrollButton(!isNearBottom);
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
return (
<div className="chat-container">
<div ref={containerRef} className="messages-list">
{messages.map(msg => (
<div key={msg.id} className="message">
<strong>{msg.sender}:</strong> {msg.text}
</div>
))}
<div ref={messagesEndRef} />
</div>
{showScrollButton && (
<button
className="scroll-to-bottom"
onClick={scrollToBottom}
aria-label="Scroll to latest messages"
>
↓ New messages
</button>
)}
</div>
);
}
Pattern 3: Measuring Elements
Sometimes you need to know an element's size or position to make layout decisions or create responsive components.
function ResponsiveDropdown() {
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<'bottom' | 'top'>('bottom');
useEffect(() => {
if (!isOpen || !triggerRef.current || !dropdownRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
// Check if dropdown would overflow viewport
const spaceBelow = window.innerHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
if (spaceBelow < dropdownRect.height && spaceAbove > dropdownRect.height) {
setDropdownPosition('top');
} else {
setDropdownPosition('bottom');
}
}, [isOpen]);
return (
<div className="dropdown-container">
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
>
Open Menu
</button>
{isOpen && (
<div
ref={dropdownRef}
className={`dropdown-menu dropdown-${dropdownPosition}`}
role="menu"
>
<button role="menuitem">Option 1</button>
<button role="menuitem">Option 2</button>
<button role="menuitem">Option 3</button>
</div>
)}
</div>
);
}
Pattern 4: Integrating Third-Party Libraries
Many libraries need direct DOM access. Refs are perfect for integrating them with React.
import Chart from 'chart.js/auto'; // Example: Chart.js
interface ChartData {
labels: string[];
values: number[];
}
interface ChartComponentProps {
data: ChartData;
type: 'bar' | 'line' | 'pie';
}
function ChartComponent({ data, type }: ChartComponentProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
// Destroy previous chart instance if it exists
if (chartRef.current) {
chartRef.current.destroy();
}
// Create new chart
chartRef.current = new Chart(canvasRef.current, {
type,
data: {
labels: data.labels,
datasets: [{
label: 'Dataset',
data: data.values,
backgroundColor: 'rgba(102, 126, 234, 0.5)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
// Cleanup: destroy chart when component unmounts
return () => {
if (chartRef.current) {
chartRef.current.destroy();
}
};
}, [data, type]);
return (
<div style={{ height: '400px', width: '100%' }}>
<canvas ref={canvasRef} />
</div>
);
}
💡 Third-Party Library Integration Tips
- Always clean up library instances in useEffect return
- Store the library instance in a ref (not state)
- Destroy and recreate when dependencies change
- Check for null before accessing DOM elements
- Consider creating custom hooks to encapsulate library logic
Pattern 5: Previous Value Tracking
Sometimes you need to know what a value was in the previous render to detect changes or revert to old values.
// Custom hook to track previous value
function usePrevious<T>(value: T): T | undefined {
// React 19: pass an explicit initial value (undefined)
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// Usage example
function CounterWithHistory() {
const [count, setCount] = useState(0);
const previousCount = usePrevious(count);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {previousCount ?? 'N/A'}</p>
<p>
{previousCount !== undefined && count > previousCount && '📈 Increased!'}
{previousCount !== undefined && count < previousCount && '📉 Decreased!'}
</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
}
Pattern 6: Imperative Handles with Videos/Audio
Media elements often need imperative control - play, pause, seek. Refs make this straightforward.
interface VideoPlayerProps {
src: string;
poster?: string;
}
function VideoPlayer({ src, poster }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const togglePlayPause = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
};
const handleSeek = (time: number) => {
if (!videoRef.current) return;
videoRef.current.currentTime = time;
setCurrentTime(time);
};
const handleTimeUpdate = () => {
if (!videoRef.current) return;
setCurrentTime(videoRef.current.currentTime);
};
const handleLoadedMetadata = () => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
};
const handleSkip = (seconds: number) => {
if (!videoRef.current) return;
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
handleSeek(newTime);
};
return (
<div className="video-player">
<video
ref={videoRef}
src={src}
poster={poster}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
/>
<div className="controls">
<button onClick={togglePlayPause}>
{isPlaying ? '⏸️ Pause' : '▶️ Play'}
</button>
<button onClick={() => handleSkip(-10)}>⏪ -10s</button>
<button onClick={() => handleSkip(10)}>⏩ +10s</button>
<input
type="range"
min="0"
max={duration}
value={currentTime}
onChange={(e) => handleSeek(Number(e.target.value))}
aria-label="Seek video"
/>
<span>
{Math.floor(currentTime)}s / {Math.floor(duration)}s
</span>
</div>
</div>
);
}
🔄 Passing Refs to Child Components
Reusable components — a styled <Input>, a <VideoPlayer>, a <Modal> — often need to let their parent reach the real element inside them, so the parent can focus it, scroll it, or play it. This is where a lot of older tutorials get outdated fast, so let's anchor on the current reality first.
💡 React 19 changed this for the better
In React 19, ref is just a regular prop. A function component can name ref in its props and pass it straight through to a child element — no wrapper, no special API. The forwardRef function you'll see all over the internet is now legacy: still supported for backward compatibility, but no longer necessary for new code. We'll teach the modern pattern first, then show forwardRef so you can recognize it in older codebases.
The Old Gotcha (React 18 and earlier)
Historically, ref was not a normal prop. If you wrote a component that accepted ref in its props, React would intercept it and your child never received it:
// ⚠️ React 18 and earlier: this did NOT work.
// React reserved `ref`, so `ref` here was always undefined.
function CustomInput({ label, ref }: { label: string; ref: any }) {
return (
<div>
<label>{label}</label>
<input ref={ref} /> {/* ref never arrived */}
</div>
);
}
The Modern Way: ref as a Prop
In React 19 that exact idea now works — you just type it properly. Declare ref in your props interface with React.Ref<T> and forward it to the element:
interface CustomInputProps {
label: string;
error?: string;
ref?: React.Ref<HTMLInputElement>;
}
// ✅ React 19: ref is a normal prop — no forwardRef needed
function CustomInput({ label, error, ref }: CustomInputProps) {
return (
<div className="input-group">
<label>{label}</label>
<input
ref={ref}
className={error ? 'input-error' : ''}
aria-invalid={!!error}
/>
{error && <span className="error-message">{error}</span>}
</div>
);
}
// Usage — identical to using a built-in element
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
useEffect(() => {
emailRef.current?.focus();
}, []);
return (
<form>
<CustomInput ref={emailRef} label="Email" />
</form>
);
}
🕰️ Legacy pattern: forwardRef
You will still meet forwardRef constantly in existing code and libraries, so recognize it. It wraps your component and delivers the ref as a second argument:
import { forwardRef } from 'react';
// Legacy (React 18) — equivalent to the modern example above
const CustomInput = forwardRef<HTMLInputElement, { label: string }>(
({ label }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} />
</div>
)
);
CustomInput.displayName = 'CustomInput';
For new React 19 code, prefer the plain ref prop. Reach for forwardRef only when you must support React 18.
Advanced: useImperativeHandle
Sometimes you don't want to expose the raw DOM element at all — you want to hand the parent a small, deliberate API instead: play(), pause(), reset(). useImperativeHandle lets you decide exactly what ref.current becomes. In React 19 it pairs naturally with the ref-as-prop pattern:
import { useImperativeHandle, useRef } from 'react';
// The API we choose to expose to the parent
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (time: number) => void;
getCurrentTime: () => number;
}
interface VideoPlayerProps {
src: string;
ref?: React.Ref<VideoPlayerHandle>;
}
// ✅ React 19: ref is a prop; useImperativeHandle shapes what it holds
function VideoPlayer({ src, ref }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play() {
videoRef.current?.play();
},
pause() {
videoRef.current?.pause();
},
seek(time: number) {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
},
getCurrentTime() {
return videoRef.current?.currentTime ?? 0;
}
}), []);
return <video ref={videoRef} src={src} />;
}
// Usage in parent — ref.current is the handle, not the <video>
function VideoApp() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<div>
<VideoPlayer ref={playerRef} src="video.mp4" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.pause()}>Pause</button>
<button onClick={() => playerRef.current?.seek(30)}>Skip to 30s</button>
</div>
);
}
✅ When to Use useImperativeHandle
- Creating library-like components with specific APIs
- Hiding implementation details from parent
- Providing controlled access to child functionality
- Building form components with validate/reset methods
- Creating media player components with playback controls
Real-World Example: Form Field Component
interface FormFieldHandle {
focus: () => void;
getValue: () => string;
setValue: (value: string) => void;
validate: () => boolean;
}
interface FormFieldProps {
label: string;
type?: 'text' | 'email' | 'password';
required?: boolean;
pattern?: string;
errorMessage?: string;
ref?: React.Ref<FormFieldHandle>;
}
// ✅ React 19: ref as a prop + useImperativeHandle for a clean API
function FormField({
label, type = 'text', required, pattern, errorMessage, ref
}: FormFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState('');
useImperativeHandle(ref, () => ({
focus() {
inputRef.current?.focus();
},
getValue() {
return inputRef.current?.value ?? '';
},
setValue(value: string) {
if (inputRef.current) {
inputRef.current.value = value;
}
},
validate() {
const value = inputRef.current?.value ?? '';
if (required && !value) {
setError('This field is required');
return false;
}
if (pattern && !new RegExp(pattern).test(value)) {
setError(errorMessage ?? 'Invalid format');
return false;
}
setError('');
return true;
}
}), [required, pattern, errorMessage]);
return (
<div className="form-field">
<label>{label} {required && <span>*</span>}</label>
<input
ref={inputRef}
type={type}
aria-required={required}
aria-invalid={!!error}
/>
{error && <span className="error">{error}</span>}
</div>
);
}
// Usage
function RegistrationForm() {
const emailRef = useRef<FormFieldHandle>(null);
const passwordRef = useRef<FormFieldHandle>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const emailValid = emailRef.current?.validate();
const passwordValid = passwordRef.current?.validate();
if (!emailValid) {
emailRef.current?.focus();
return;
}
if (!passwordValid) {
passwordRef.current?.focus();
return;
}
// Submit form
const email = emailRef.current?.getValue();
const password = passwordRef.current?.getValue();
console.log('Submitting:', { email, password });
};
return (
<form onSubmit={handleSubmit}>
<FormField
ref={emailRef}
label="Email"
type="email"
required
pattern="^[^\s@]+@[^\s@]+\.[^\s@]+$"
errorMessage="Please enter a valid email"
/>
<FormField
ref={passwordRef}
label="Password"
type="password"
required
pattern=".{8,}"
errorMessage="Password must be at least 8 characters"
/>
<button type="submit">Register</button>
</form>
);
}
🏋️ Hands-on Practice
Let's apply what you've learned with practical exercises. Try to complete each one before looking at the solution!
🏋️ Exercise 1: Image Gallery with Keyboard Navigation
Goal: Build an image gallery where arrow keys navigate between images and the current image is always in view.
Requirements:
- Display a list of images with thumbnails
- Use Left/Right arrow keys to navigate
- Automatically scroll selected image into view
- Highlight the currently selected image
💡 Hint
You'll need:
- An array of refs (one for each image)
- State to track current index
- useEffect to add keyboard event listener
- scrollIntoView() when index changes
✅ Solution
interface Image {
id: string;
url: string;
alt: string;
}
interface ImageGalleryProps {
images: Image[];
}
function ImageGallery({ images }: ImageGalleryProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
// Initialize refs array
useEffect(() => {
imageRefs.current = imageRefs.current.slice(0, images.length);
}, [images]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
setSelectedIndex(prev =>
prev > 0 ? prev - 1 : images.length - 1
);
} else if (e.key === 'ArrowRight') {
setSelectedIndex(prev =>
prev < images.length - 1 ? prev + 1 : 0
);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [images.length]);
// Scroll selected image into view
useEffect(() => {
imageRefs.current[selectedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}, [selectedIndex]);
return (
<div className="gallery">
<div className="thumbnails">
{images.map((image, index) => (
<div
key={image.id}
ref={el => imageRefs.current[index] = el}
className={`thumbnail ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => setSelectedIndex(index)}
role="button"
tabIndex={0}
aria-label={`View ${image.alt}`}
>
<img src={image.url} alt={image.alt} />
</div>
))}
</div>
<div className="main-image">
<img
src={images[selectedIndex].url}
alt={images[selectedIndex].alt}
/>
<p>{selectedIndex + 1} / {images.length}</p>
</div>
<p className="hint">Use ← → keys to navigate</p>
</div>
);
}
🏋️ Exercise 2: Click-Outside to Close
Goal: Create a modal or dropdown that closes when you click outside of it.
Requirements:
- Show/hide a dropdown on button click
- Close dropdown when clicking outside
- Don't close when clicking inside the dropdown
- Clean up event listeners properly
💡 Hint
Use useRef for the dropdown element and useEffect to add a document click listener that checks if the click target is inside the ref.
✅ Solution
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
// Add listener after a brief delay to avoid immediate close
const timeoutId = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className="dropdown-container" ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle Menu
</button>
{isOpen && (
<div className="dropdown-menu">
<button>Option 1</button>
<button>Option 2</button>
<button>Option 3</button>
</div>
)}
</div>
);
}
🏋️ Exercise 3: Debounced Search with Auto-focus
Goal: Create a search input that auto-focuses on mount and debounces the search query.
Requirements:
- Auto-focus the input when component mounts
- Debounce the search (wait 500ms after typing stops)
- Display search results
- Clear timeout on unmount
💡 Hint
You'll need a ref for the input element and another ref to store the timeout ID so you can clear it.
✅ Solution
interface SearchResult {
id: string;
title: string;
}
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Debounced search
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (!query) {
setResults([]);
return;
}
setIsSearching(true);
// Set new timeout
timeoutRef.current = setTimeout(() => {
// Simulate API call
const mockResults: SearchResult[] = [
{ id: '1', title: `Result for "${query}" 1` },
{ id: '2', title: `Result for "${query}" 2` },
{ id: '3', title: `Result for "${query}" 3` }
];
setResults(mockResults);
setIsSearching(false);
}, 500);
// Cleanup
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [query]);
return (
<div className="search-container">
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
aria-label="Search"
/>
{isSearching && <p>Searching...</p>}
<ul className="results">
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
{query && !isSearching && results.length === 0 && (
<p>No results found</p>
)}
</div>
);
}
🏋️ Challenge: Infinite Scroll
Goal: Implement infinite scroll that loads more items when user scrolls near the bottom.
Requirements:
- Display a list of items
- Detect when user scrolls near bottom (within 100px)
- Load more items automatically
- Show loading indicator
- Prevent multiple simultaneous loads
✅ Solution
interface Item {
id: number;
title: string;
}
function InfiniteScrollList() {
const [items, setItems] = useState<Item[]>(
Array.from({ length: 20 }, (_, i) => ({
id: i,
title: `Item ${i + 1}`
}))
);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
const loadMore = async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setIsLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
const newItems = Array.from({ length: 20 }, (_, i) => ({
id: items.length + i,
title: `Item ${items.length + i + 1}`
}));
setItems(prev => [...prev, ...newItems]);
setIsLoading(false);
loadingRef.current = false;
// Stop after 100 items
if (items.length >= 80) {
setHasMore(false);
}
};
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// Load more when within 100px of bottom
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [items, hasMore]);
return (
<div
ref={containerRef}
className="scroll-container"
style={{ height: '500px', overflow: 'auto' }}
>
<h2>Infinite Scroll Demo</h2>
<ul>
{items.map(item => (
<li key={item.id} style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
{item.title}
</li>
))}
</ul>
{isLoading && (
<div style={{ textAlign: 'center', padding: '1rem' }}>
Loading more items...
</div>
)}
{!hasMore && (
<div style={{ textAlign: 'center', padding: '1rem' }}>
No more items to load
</div>
)}
</div>
);
}
✅ Best Practices
Follow these guidelines to use refs effectively and avoid common pitfalls.
✅ Do's
- Use refs for DOM access: Focus inputs, measure elements, scroll positions - these are perfect ref use cases
- Store mutable values that don't affect rendering: Timer IDs, subscription objects, previous values
- Always check for null: Use optional chaining (
ref.current?.) since refs can be null - Clean up in useEffect: Clear intervals, close connections, destroy library instances
- Use TypeScript generics:
useRef<HTMLInputElement>(null)for proper typing - Initialize DOM refs with null:
useRef<HTMLElement>(null)is the convention - Accept
refas a prop for reusable components (React 19): type it asReact.Ref<T>— noforwardRefneeded for new code - Expose a minimal handle with
useImperativeHandle: give parents a small API instead of the raw DOM node - Combine with other hooks: Refs work great with useState, useEffect, useCallback
- Document imperative APIs: When using useImperativeHandle, document what methods are exposed
❌ Don'ts
- Don't use refs instead of state for rendering: If it affects what the user sees, it should be state
- Don't read/write refs during rendering: Refs should be accessed in event handlers or effects, not during render
- Don't forget cleanup: Always clear intervals, timeouts, and subscriptions stored in refs
- Don't mutate ref.current during render: This can cause confusing bugs and inconsistent renders
- Don't use refs to pass data between components: Use props or context instead
- Don't expose entire DOM nodes unnecessarily: Use useImperativeHandle to expose only what's needed
- Don't forget about accessibility: Just because you can control focus doesn't mean you should abuse it
- Don't use refs for derived values: Calculate them during render instead
- Don't create refs in loops without keys: Use useRef inside map callbacks carefully
- Don't rely on ref updates for re-renders: Changing ref.current doesn't trigger re-renders
💡 Pro Tips
- Create custom hooks with refs: Encapsulate common ref patterns like usePrevious, useClickOutside
- Use callback refs for dynamic lists: When you need refs to array items, callback refs are more flexible
- Combine refs with Intersection Observer: Great for lazy loading and infinite scroll
- Store instances of classes in refs: Perfect for third-party library instances
- Use refs to prevent stale closures: Store latest values in refs when using setInterval
- Memoize callback refs: Use useCallback for callback refs to avoid unnecessary recreations
- Debug with ref.current in DevTools: Set a breakpoint and inspect ref values
- Create ref-based animations: Use refs with requestAnimationFrame for smooth animations
- Batch DOM operations with refs: Make multiple DOM changes efficiently outside React's render cycle
🎯 Quick Decision Guide
| Scenario | Use This | Not This |
|---|---|---|
| Value displayed to user | useState | useRef |
| Timer ID for cleanup | useRef | useState |
| Focus an input | useRef | document.querySelector |
| Passing data down | Props/Context | useRef |
| Tracking previous value | useRef | useState |
⚠️ Common Mistakes to Avoid
Learn from these common mistakes so you don't have to make them yourself!
Mistake 1: Reading Refs During Render
// ❌ Bad - reading ref during render
function BadComponent() {
const countRef = useRef(0);
countRef.current++; // Don't do this!
return <div>Renders: {countRef.current}</div>;
}
// ✅ Good - use state for values shown to user
function GoodComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(prev => prev + 1);
});
return <div>Renders: {count}</div>;
}
Mistake 2: Expecting Refs to Trigger Re-renders
// ❌ Bad - expecting ref change to update UI
function BadCounter() {
const countRef = useRef(0);
const increment = () => {
countRef.current++; // UI won't update!
};
return (
<div>
<p>Count: {countRef.current}</p> {/* Stuck at 0 */}
<button onClick={increment}>+</button>
</div>
);
}
// ✅ Good - use state for UI updates
function GoodCounter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
Mistake 3: Forgetting Cleanup
// ❌ Bad - no cleanup, memory leak!
function BadTimer() {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
timerRef.current = setInterval(() => {
console.log('tick');
}, 1000);
// Missing cleanup!
}, []);
return <div>Timer running</div>;
}
// ✅ Good - proper cleanup
function GoodTimer() {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
timerRef.current = setInterval(() => {
console.log('tick');
}, 1000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
return <div>Timer running</div>;
}
Mistake 4: Not Checking for Null
// ❌ Bad - might crash!
function BadFocus() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current.focus(); // Might be null!
}, []);
return <input ref={inputRef} />;
}
// ✅ Good - null check
function GoodFocus() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // Safe with optional chaining
}, []);
return <input ref={inputRef} />;
}
Mistake 5: Using Wrong Initial Value
// ❌ Bad - missing initial argument
function BadRef() {
const inputRef = useRef<HTMLInputElement>(); // Type error in React 19!
return <input ref={inputRef} />;
}
// ❌ Also bad - mutable value with no initial value
function AlsoBad() {
const countRef = useRef<number>(); // Type error — and would be undefined
countRef.current++; // NaN!
}
// ✅ Good - proper initialization
function GoodRefs() {
const inputRef = useRef<HTMLInputElement>(null); // DOM refs start as null
const countRef = useRef(0); // Mutable values have initial value
return <input ref={inputRef} />;
}
Mistake 6: Stale Closures with Refs
// ❌ Bad - stale closure captures old value
function BadCallback({ onSave }: { onSave: (data: string) => void }) {
const [data, setData] = useState('');
useEffect(() => {
const interval = setInterval(() => {
onSave(data); // Always uses initial data value!
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps = stale closure
return <input value={data} onChange={e => setData(e.target.value)} />;
}
// ✅ Good - ref always has latest value
function GoodCallback({ onSave }: { onSave: (data: string) => void }) {
const [data, setData] = useState('');
const dataRef = useRef(data);
// Keep ref in sync
useEffect(() => {
dataRef.current = data;
}, [data]);
useEffect(() => {
const interval = setInterval(() => {
onSave(dataRef.current); // Always uses latest data!
}, 1000);
return () => clearInterval(interval);
}, [onSave]);
return <input value={data} onChange={e => setData(e.target.value)} />;
}
⚠️ Remember
- Refs are for DOM access and mutable values, not UI state
- Changing refs doesn't trigger re-renders
- Always clean up subscriptions, timers, and connections
- Use optional chaining with refs to avoid crashes
- Initialize DOM refs with null, mutable values with actual values
- Use refs to solve stale closure problems in effects
📚 Summary
🎉 Key Takeaways
- useRef creates a mutable container that persists across renders without triggering re-renders
- Two main use cases: accessing DOM elements and storing mutable values
- Refs vs State: Use state for UI, refs for everything else that needs to persist
- Always check for null when accessing refs (use optional chaining)
- TypeScript typing:
useRef<Type>(initialValue)for proper type safety - Common patterns: Focus management, scroll control, measuring elements, integrating libraries
- In React 19,
refis a normal prop — pass refs to child components directly;forwardRefis legacy - useImperativeHandle customizes what parent components can access via the ref
- Clean up subscriptions stored in refs to prevent memory leaks
- Don't read/write refs during render - use effects or event handlers instead
🎯 When to Use useRef
| Use Case | Example |
|---|---|
| DOM Access | Focus inputs, scroll to elements, measure sizes |
| Timers | Store setInterval/setTimeout IDs for cleanup |
| Previous Values | Track what props/state was in last render |
| Library Integration | Store Chart.js instances, WebSocket connections |
| Avoiding Re-renders | Store values that don't affect UI |
| Mutable Trackers | Count renders, track component mount status |
🔄 The Complete Picture
📚 Additional Resources
- React useRef Documentation
- React forwardRef Documentation
- React useImperativeHandle Documentation
- Refs Pattern - Patterns.dev
- Kent C. Dodds - When to Use Refs
🚀 What's Next?
In the next lesson, we'll explore useMemo and useCallback - React's performance optimization hooks. You'll learn when and how to memoize values and functions to prevent unnecessary re-renders and expensive calculations. These hooks work great alongside useRef for building highly performant React applications!
🎯 Practice Challenge
Before moving on, try building one of these projects using useRef:
- Custom Video Player: Build a complete video player with play/pause, seek, volume, and fullscreen controls
- Drawing Canvas: Create a
<canvas>where users can draw with mouse/touch, with undo/redo functionality - Auto-Save Form: Build a form that auto-saves a draft to localStorage every 5 seconds using refs
- Notification System: Create toast notifications that auto-dismiss and can be manually closed
🎉 Congratulations!
You've mastered one of React's most versatile hooks! You now understand how to work with the DOM imperatively, store mutable values that survive renders, pass refs to child components the modern React 19 way, and sidestep the common pitfalls. Refs are your secret weapon for the moments when React's declarative approach needs a little imperative help. Keep practicing and you'll find them invaluable in your React toolkit! 🚀