Skip to main content

🎯 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:

⚡ Interactive: Refs vs Variables Across Renders

Watch how refs persist while regular variables reset on each render

What Happens When Component Re-renders? Render Count 1 ❌ Regular Variable let count = 0 Variable Value: 0 Resets to 0 on every render! ✅ useRef const ref = useRef(0) ref.current: 0 Persists across renders! Click buttons to simulate renders and increments
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

sequenceDiagram participant Component participant useRef participant RefObject Component->>useRef: Call useRef(initialValue) useRef->>RefObject: Create { current: initialValue } RefObject->>Component: Return ref object Note over Component: Component re-renders... Component->>RefObject: Read/write ref.current RefObject->>Component: Same ref object Note over RefObject: Ref persists
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

  1. Create a ref with useRef<HTMLInputElement>(null)
  2. React sets inputRef.current to the DOM node when it mounts
  3. You can access the DOM node via inputRef.current
  4. React sets inputRef.current back to null when it unmounts

The ref Lifecycle with DOM

🎬 Interactive: DOM Ref Lifecycle

Watch what happens to ref.current during component mount and unmount

DOM Ref Lifecycle: null → element → null 1 Create Ref useRef(null) 2 Render JSX <input ref={ref}/> 3 DOM Created ref.current = element 4 Use Ref ref.current.focus() 5 Unmount ref.current = null ref.current = null <input type="text" /> Click "Play Lifecycle" to see the animation
sequenceDiagram participant React participant Ref participant DOM React->>Ref: Create ref (current: null) React->>DOM: Create DOM element React->>Ref: Set ref.current = DOM element Note over Ref,DOM: Component is mounted
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

🔄 Interactive: Ref vs State Re-render Behavior

See how state triggers re-renders while refs don't update the UI

State vs Ref: What Triggers Re-renders? MyComponent Re-rendering! useState count: 0 Displayed in UI ✅ Triggers re-render ✅ useRef ref.current: 0 Actual value ✔ No re-render ❌ What User Sees on Screen: MyComponent Output State Count: 0 Ref Count: 0 ← stale! Total Renders: 1 Click buttons to see the difference

💡 Key Insight: The ref value changes but the screen doesn't update because refs don't trigger re-renders. Only when state changes does React update the UI!

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?

graph TD A[Need to store a value?] --> B{Does it affect what user sees?} B -->|Yes| C[Use useState] B -->|No| D{Does it need to persist across renders?} D -->|Yes| E[Use useRef] D -->|No| F[Use regular variable] C --> G[Examples: form data, UI state, counts displayed on screen] E --> H[Examples: DOM refs, timer IDs, previous values] F --> I[Examples: temporary calculations, loop counters] style C fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff style E fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style F fill:#f093fb,stroke:#333,stroke-width:2px,color:#fff

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 null as 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.

🎯 Interactive: Focus Management with Refs

See how refs enable programmatic focus control

Login Form Demo

Click buttons above to programmatically focus inputs using refs!

const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);

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

// Focus first invalid field on submit
const focusFirstEmpty = () => {
  if (!emailRef.current?.value) {
    emailRef.current?.focus();
  } else if (!passwordRef.current?.value) {
    passwordRef.current?.focus();
  }
};
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-invalid and aria-describedby for 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

graph LR A[useRef] --> B[DOM Refs] A --> C[Mutable Values] B --> D[Focus Management] B --> E[Scroll Control] B --> F[Measurements] C --> G[Timer IDs] C --> H[Previous Values] C --> I[Library Instances] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#48bb78,stroke:#333,stroke-width:2px style C fill:#48bb78,stroke:#333,stroke-width:2px

📚 Additional Resources

🚀 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! 🚀