🎯 useRef Hook
Welcome to one of React's most versatile hooks! You've learned about useState for state that triggers re-renders, but what about values that need to persist between renders WITHOUT causing re-renders? Or when you need to directly access a DOM element to focus an input, measure its size, or integrate with a third-party library? That's where useRef comes in! Think of useRef as a special container that can hold a value that "remembers" across renders, like a box that stays in the same spot while everything else around it changes. It's your secret weapon for working with the DOM and storing mutable values. Let's unlock this superpower! 🔮
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand what refs are and when to use them
- Access DOM elements directly with useRef
- Store mutable values that persist across renders
- Understand the difference between refs and state
- Type refs correctly with TypeScript
- Implement common ref patterns (focus, scroll, animation)
- Forward refs to child components
- Avoid common ref mistakes and anti-patterns
- Combine refs with other hooks effectively
Estimated Time: 60-75 minutes
Project: Build focus management, auto-scroll, and animation controls
📑 In This Lesson
🎯 Introduction to Refs
Before we dive into useRef, let's understand what refs are and why React needs them.
📖 Definition
Ref (Reference): A ref is a plain JavaScript object with a single property called current that you can read and modify. Unlike state, updating a ref does NOT trigger a re-render. Refs "escape" React's rendering cycle.
When Do You Need Refs?
React is declarative - you describe what the UI should look like, and React figures out how to make it happen. But sometimes you need to "escape" this declarative world and work imperatively with the DOM or store values. That's when refs shine.
✅ 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 explore how useRef works at a fundamental level. Understanding this will help you use it effectively.
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
One of the most common uses of refs is to access DOM elements directly. This is how you "break out" of React's declarative world when you need to.
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 accessing ref.current - Check if ref.current exists before using it
- Initialize DOM refs with
null - Type your refs with the correct HTML element type
- Clean up event listeners and timers in useEffect
⚖️ 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 {
const ref = useRef<T>();
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
TypeScript makes refs much safer by ensuring you use the correct types. Let's master ref typing!
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 (number or NodeJS.Timeout depending on environment)
const timerRef = useRef<number | 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
Beyond DOM access, refs are perfect for storing mutable values that don't affect rendering. Let's explore common patterns.
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 {
const ref = useRef<T>();
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>
);
}
🔄 Forwarding Refs
Sometimes you need to pass a ref from a parent component down to a child component. This is called "ref forwarding" and it's essential for creating reusable components that need DOM access.
The Problem: Refs Don't Pass Like Props
Unlike regular props, refs are handled specially by React. You can't just pass a ref as a prop - you need to use forwardRef.
// ❌ This doesn't work - ref is not a prop
interface InputProps {
label: string;
ref: React.RefObject<HTMLInputElement>; // Won't work!
}
function CustomInput({ label, ref }: InputProps) {
return (
<div>
<label>{label}</label>
<input ref={ref} /> {/* ref is undefined! */}
</div>
);
}
The Solution: forwardRef
forwardRef is a special function that lets your component receive a ref and pass it down to a child element.
import { forwardRef } from 'react';
interface CustomInputProps {
label: string;
error?: string;
}
// ✅ Correct way: use forwardRef
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
({ label, error }, ref) => {
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>
);
}
);
CustomInput.displayName = 'CustomInput'; // Good practice for debugging
// Usage
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
useEffect(() => {
emailRef.current?.focus();
}, []);
return (
<form>
<CustomInput ref={emailRef} label="Email" />
</form>
);
}
📖 forwardRef Syntax
const Component = forwardRef<RefType, PropsType>(
(props, ref) => {
// Component logic
return <element ref={ref} />
}
);
Advanced: useImperativeHandle
Sometimes you don't want to expose the entire DOM element - you only want to expose specific methods. useImperativeHandle lets you customize what the parent sees.
import { forwardRef, useImperativeHandle, useRef } from 'react';
// Define what methods we expose to parent
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (time: number) => void;
getCurrentTime: () => number;
}
interface VideoPlayerProps {
src: string;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
({ src }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
// Expose only these methods to parent
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} />;
}
);
VideoPlayer.displayName = 'VideoPlayer';
// Usage in parent
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;
}
const FormField = forwardRef<FormFieldHandle, FormFieldProps>(
({ label, type = 'text', required, pattern, errorMessage }, ref) => {
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>
);
}
);
FormField.displayName = 'FormField';
// 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<NodeJS.Timeout>();
// 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 - Use forwardRef for reusable components: Makes your components more flexible
- Set displayName on forwarded refs: Helps with debugging in React DevTools
- 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<NodeJS.Timeout>();
useEffect(() => {
timerRef.current = setInterval(() => {
console.log('tick');
}, 1000);
// Missing cleanup!
}, []);
return <div>Timer running</div>;
}
// ✅ Good - proper cleanup
function GoodTimer() {
const timerRef = useRef<NodeJS.Timeout>();
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 - DOM ref initialized with undefined
function BadRef() {
const inputRef = useRef<HTMLInputElement>(); // Type error!
return <input ref={inputRef} />;
}
// ❌ Also bad - mutable value initialized incorrectly
function AlsoBad() {
const countRef = useRef<number>(); // undefined, not 0!
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
- forwardRef lets you pass refs to child components
- useImperativeHandle customizes what parent components can access
- 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 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 powerful hooks! You now understand how to work with the DOM imperatively, store mutable values, forward refs to child components, and avoid common pitfalls. Refs are your secret weapon for scenarios where React's declarative approach needs a little imperative help. Keep practicing and you'll find refs invaluable in your React toolkit! 🚀