Skip to main content

🎯 useRef Hook

Welcome to one of React's most versatile hooks! You already know useState for values that should trigger a re-render when they change. But what about a value that needs to survive from one render to the next without repainting the screen every time it changes? Or the moment when you need to reach past React and touch a real DOM element directly — to focus an input, measure a box, or hand a <canvas> to a charting library? That is exactly the gap useRef fills. Think of a ref as a little box bolted to the wall of your component: renders come and go, the furniture gets rearranged, but the box stays put and keeps whatever you left inside it. By the end of this lesson you'll reach for refs with confidence — and, just as importantly, you'll know when not to. 🔮

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a ref is and the two problems it solves
  • Access DOM elements directly and safely with useRef
  • Store mutable values that persist across renders without re-rendering
  • Choose correctly between a ref and state, and explain why
  • Type refs precisely with TypeScript 5.x (including the new required initial argument)
  • Implement everyday ref patterns: focus, scroll, measurement, previous-value
  • Pass refs to child components the modern React 19 way (ref as a normal prop)
  • Expose a controlled imperative API with useImperativeHandle
  • Avoid the common ref mistakes and anti-patterns that trip up beginners

Estimated Time: 60-75 minutes

Project: Build focus management, auto-scroll, and media controls

In This Lesson

🎯 Introduction to Refs

Before we wire up a single useRef call, let's slow down and build a solid mental model of what a ref actually is — because almost every ref bug people hit comes from misunderstanding this one idea.

📖 Definition

Ref (short for "reference"): a plain JavaScript object with a single property, current, that you can freely read and write. The key difference from state: writing to ref.current does not schedule a re-render. A ref quietly holds a value off to the side, outside React's rendering cycle.

Why Does React Even Need Refs?

React is declarative. You describe what the UI should look like for a given set of props and state, and React figures out the DOM operations to make that happen. You rarely touch the DOM yourself — and that's a feature, not a limitation. But every now and then you genuinely need to step outside that declarative flow: to imperatively call .focus() on an input, read an element's measured height, or pause a <video>. The DOM is imperative by nature, and some browser APIs and third-party libraries simply expect a real element handle. Refs are React's official, blessed escape hatch for exactly these moments — a controlled door out of declarative-land, not a hole in the wall.

✅ Use Refs When You Need To:

  • Access DOM elements - Focus inputs, measure sizes, scroll positions
  • Store mutable values - Values that don't affect rendering (timers, IDs)
  • Integrate with non-React code - Third-party libraries, animations
  • Avoid re-renders - Store values without triggering updates
  • Track previous values - Remember the last state or props
  • Imperatively control things - Play/pause video, trigger animations

⚠️ Don't Use Refs For:

  • Rendering data - Use state for that
  • Triggering re-renders - That's what setState is for
  • Replacing state - State is better when UI depends on the value
  • Storing derived values - Calculate them during render instead

The Two Main Use Cases

Use Case Example Why Ref?
DOM Access Focus an input, scroll to element Need direct access to DOM node
Mutable Values Store timer ID, previous value Value changes, but doesn't affect UI

Real-World Scenarios

When Refs Solve Real Problems

  • 🎥 Video Player - Control playback imperatively
  • 📝 Form Focus - Focus first invalid field on submit
  • 📜 Infinite Scroll - Detect when user scrolls to bottom
  • 🎨 Canvas Drawing - Get canvas context for drawing
  • ⏱️ Timers - Store interval/timeout IDs for cleanup
  • 📏 Measurements - Get element width/height
  • 🔊 Audio Control - Play/pause audio programmatically
  • 📸 Camera Access - Control camera stream

🔍 Understanding useRef

Let's look under the hood. Once you see what useRef actually returns and how React treats it, every pattern later in this lesson becomes obvious rather than magical.

⚠️ React 19 / TypeScript 5.x: the initial argument is now required

In older React type definitions you could write useRef<HTMLInputElement>() with empty parentheses. As of React 19, useRef always requires an initial value — even if that value is null or undefined. So write useRef<HTMLInputElement>(null) for DOM refs and useRef<T | undefined>(undefined) for a value that genuinely starts empty. Every example in this lesson follows the new rule.

The Basic Syntax

import { useRef } from 'react';

function MyComponent() {
    // Create a ref
    const myRef = useRef(initialValue);
    
    // Access the current value
    console.log(myRef.current); // initialValue
    
    // Modify the current value
    myRef.current = newValue;
    
    return <div>Hello</div>;
}

What useRef Returns

useRef returns an object with a single property:

// This is what useRef returns:
{
    current: yourInitialValue
}

// You can think of it like this simple object:
const ref = { current: 0 };
ref.current = 5; // You can change it
console.log(ref.current); // 5

💡 Key Insight

The ref object itself NEVER changes - it's the same object on every render. Only ref.current can be modified. This is why refs don't trigger re-renders - React doesn't track changes to the current property!

Refs Persist Across Renders

Unlike regular variables, refs maintain their value across component re-renders:

⚡ 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

This is the use case most people meet first: you have a real element on the page — an input, a video, a scrollable list — and you need to do something to it that JSX can't express. You can't write "focus this input" declaratively; focusing is an action, a verb. Refs give you a handle to the underlying DOM node so you can call those verbs directly. The pattern is always the same three steps: create a ref, attach it to an element with the ref attribute, then reach for ref.current in an event handler or effect once the element exists.

Basic DOM Ref Example

To attach a ref to a DOM element, pass it to the ref attribute:

import { useRef } from 'react';

function FocusInput() {
    // Create a ref for the input element
    const inputRef = useRef<HTMLInputElement>(null);
    
    const handleFocus = () => {
        // Access the DOM element via ref.current
        inputRef.current?.focus();
    };
    
    return (
        <div>
            <input ref={inputRef} type="text" />
            <button onClick={handleFocus}>Focus Input</button>
        </div>
    );
}

💡 How It Works

  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 that ref.current exists before using it (it is null before mount and after unmount)
  • Initialize DOM refs with null
  • Type your refs with the correct HTML element type
  • Clean up event listeners and timers in a useEffect return function

⚖️ Refs vs State

Understanding when to use refs versus state is crucial for writing good React code. Let's explore the differences in depth and learn when to use each.

The Fundamental Difference

🔄 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 the 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 a regular variable"] C --> G["Examples: form data, UI state, counts 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 {
    // React 19 requires an initial argument — pass undefined explicitly
    const ref = useRef<T | undefined>(undefined);

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

    return ref.current;
}

// Usage
function Counter() {
    const [count, setCount] = useState(0);
    const prevCount = usePrevious(count);
    
    return (
        <div>
            <p>Current: {count}</p>
            <p>Previous: {prevCount}</p>
            <button onClick={() => setCount(count + 1)}>
                Increment
            </button>
        </div>
    );
}

🔷 Typing Refs with TypeScript

Refs are one of the places where TypeScript quietly earns its keep. The type you hand to useRef flows straight into ref.current, so getting it right means the compiler catches the classic "cannot read focus of null" crash before you ever run the code. The whole game comes down to one question: can this ref legitimately be empty? DOM refs start out empty (the element doesn't exist until React mounts it), so their type includes null. Value refs that always hold something can be typed as exactly that something. Let's make both cases second nature.

Basic Ref Typing

// Generic syntax: useRef<Type>(initialValue)

// DOM element refs - always start with null
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

// Value refs - can start with any value
const countRef = useRef<number>(0);
const nameRef = useRef<string>('');
const dataRef = useRef<User | null>(null);

Common HTML Element Types

Element TypeScript Type Example
<input> HTMLInputElement useRef<HTMLInputElement>(null)
<button> HTMLButtonElement useRef<HTMLButtonElement>(null)
<div> HTMLDivElement useRef<HTMLDivElement>(null)
<form> HTMLFormElement useRef<HTMLFormElement>(null)
<video> HTMLVideoElement useRef<HTMLVideoElement>(null)
<canvas> HTMLCanvasElement useRef<HTMLCanvasElement>(null)
<img> HTMLImageElement useRef<HTMLImageElement>(null)
Generic element HTMLElement useRef<HTMLElement>(null)

Why null for DOM Refs?

// ✅ Correct - DOM refs start as null
const inputRef = useRef<HTMLInputElement>(null);

// TypeScript knows ref.current might be null
inputRef.current?.focus(); // Use optional chaining

// ❌ Wrong - TypeScript error!
const inputRef = useRef<HTMLInputElement>();
// Error: Argument of type 'undefined' is not assignable to parameter of type 'HTMLInputElement | null'

💡 Why null?

DOM elements don't exist until React creates them during render. So refs that hold DOM elements must start as null. React will set ref.current to the actual DOM node after mounting.

Typing Value Refs

// Simple types
const countRef = useRef<number>(0);
const nameRef = useRef<string>('John');
const isActiveRef = useRef<boolean>(false);

// Complex types
interface User {
    id: number;
    name: string;
}

const userRef = useRef<User | null>(null);
const usersRef = useRef<User[]>([]);

// Timer IDs — the browser and Node return different types.
// ReturnType<typeof setInterval> works in both environments:
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

// WebSocket or other APIs
const socketRef = useRef<WebSocket | null>(null);

Optional Chaining with Refs

function SafeRefAccess() {
    const inputRef = useRef<HTMLInputElement>(null);
    
    const focusInput = () => {
        // ✅ Safe - won't crash if ref.current is null
        inputRef.current?.focus();
        
        // ✅ Safe - check with if statement
        if (inputRef.current) {
            inputRef.current.focus();
        }
        
        // ❌ Unsafe - could crash!
        // inputRef.current.focus(); // TypeScript error
    };
    
    return <input ref={inputRef} />;
}

Mutable vs Immutable Refs

// Mutable ref (default) - you can change .current
const mutableRef = useRef<number>(0);
mutableRef.current = 5; // ✅ Allowed

// Read-only ref (rare, but possible)
const readonlyRef = useRef<number>(0) as { readonly current: number };
// readonlyRef.current = 5; // ❌ TypeScript error

Generic Ref Typing

// Generic function that works with any ref type
function useLatest<T>(value: T) {
    const ref = useRef<T>(value);
    
    useEffect(() => {
        ref.current = value;
    }, [value]);
    
    return ref;
}

// Usage with different types
const nameLatest = useLatest('John'); // RefObject<string>
const countLatest = useLatest(42); // RefObject<number>
const userLatest = useLatest({ id: 1, name: 'Alice' }); // RefObject<User>

Ref Type Patterns

// Pattern 1: Union type for multiple possibilities
const elementRef = useRef<HTMLDivElement | HTMLSpanElement>(null);

// Pattern 2: Nullable ref
const optionalRef = useRef<string | null>(null);

// Pattern 3: Array of refs
const itemRefs = useRef<HTMLDivElement[]>([]);

// Pattern 4: Ref to custom component (we'll cover this later)
const customRef = useRef<CustomComponentHandle>(null);

✅ TypeScript Ref Best Practices

  • Always specify the type parameter: useRef<Type>
  • Use 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

DOM access gets all the attention, but the second job of useRef is just as useful and far more subtle: it's a place to stash a value that must outlive individual renders yet has no business appearing on screen. Think of the things a class component used to keep as instance fields — a timer id, an open socket, a "am I still mounted?" flag, the previous value of a prop. In a function component those can't be plain local variables (they'd reset every render) and they shouldn't be state (changing them shouldn't repaint anything). A ref is the perfect middle ground: persistent like state, silent like a variable. Here are the patterns you'll use again and again.

Pattern 1: Timer IDs

Store timer IDs to clean them up later:

function Stopwatch() {
    const [time, setTime] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    const intervalRef = useRef<number | null>(null);
    
    const start = () => {
        if (!isRunning) {
            setIsRunning(true);
            intervalRef.current = setInterval(() => {
                setTime(t => t + 10);
            }, 10);
        }
    };
    
    const stop = () => {
        if (intervalRef.current) {
            clearInterval(intervalRef.current);
            setIsRunning(false);
        }
    };
    
    const reset = () => {
        stop();
        setTime(0);
    };
    
    // Cleanup on unmount
    useEffect(() => {
        return () => {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
            }
        };
    }, []);
    
    return (
        <div>
            <p>Time: {(time / 1000).toFixed(2)}s</p>
            <button onClick={start}>Start</button>
            <button onClick={stop}>Stop</button>
            <button onClick={reset}>Reset</button>
        </div>
    );
}

Pattern 2: Previous Values

Track previous state or props:

function ValueChangeDetector({ value }: { value: number }) {
    const prevValueRef = useRef<number>(value);
    
    useEffect(() => {
        const prev = prevValueRef.current;
        
        if (prev !== value) {
            console.log(`Value changed from ${prev} to ${value}`);
        }
        
        // Update ref for next render
        prevValueRef.current = value;
    }, [value]);
    
    return <div>Current value: {value}</div>;
}

Pattern 3: Instance Variables

Store class-like instance variables in functional components:

function DataFetcher() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    
    // Track if component is mounted to prevent setState on unmounted component
    const isMountedRef = useRef(true);
    
    useEffect(() => {
        return () => {
            isMountedRef.current = false;
        };
    }, []);
    
    const fetchData = async () => {
        setLoading(true);
        
        try {
            const response = await fetch('/api/data');
            const result = await response.json();
            
            // Only update state if still mounted
            if (isMountedRef.current) {
                setData(result);
            }
        } finally {
            if (isMountedRef.current) {
                setLoading(false);
            }
        }
    };
    
    return (
        <div>
            <button onClick={fetchData}>Fetch Data</button>
            {loading && <p>Loading...</p>}
        </div>
    );
}

Pattern 4: Callback Refs

Store the latest version of a callback:

function useLatestCallback<T extends (...args: any[]) => any>(callback: T) {
    const callbackRef = useRef(callback);
    
    // Update ref whenever callback changes
    useEffect(() => {
        callbackRef.current = callback;
    }, [callback]);
    
    // Return stable callback that always calls the latest version
    const stableCallback = useCallback(
        (...args: Parameters<T>) => callbackRef.current(...args),
        []
    );
    
    return stableCallback;
}

// Usage
function SearchBox({ onSearch }: { onSearch: (query: string) => void }) {
    const [query, setQuery] = useState('');
    
    // onSearch might change, but we want stable reference for useEffect
    const stableOnSearch = useLatestCallback(onSearch);
    
    useEffect(() => {
        const timer = setTimeout(() => {
            stableOnSearch(query);
        }, 500);
        
        return () => clearTimeout(timer);
    }, [query]); // Only query in deps, not onSearch!
    
    return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Pattern 5: Web APIs

Store references to web APIs:

function WebSocketChat() {
    const [messages, setMessages] = useState<string[]>([]);
    const socketRef = useRef<WebSocket | null>(null);
    
    useEffect(() => {
        // Create WebSocket connection
        socketRef.current = new WebSocket('ws://localhost:8080');
        
        socketRef.current.onmessage = (event) => {
            setMessages(prev => [...prev, event.data]);
        };
        
        // Cleanup on unmount
        return () => {
            socketRef.current?.close();
        };
    }, []);
    
    const sendMessage = (message: string) => {
        socketRef.current?.send(message);
    };
    
    return (
        <div>
            {messages.map((msg, i) => (
                <p key={i}>{msg}</p>
            ))}
            <button onClick={() => sendMessage('Hello')}>
                Send Hello
            </button>
        </div>
    );
}

💡 When to Use Mutable Refs

Use refs for mutable values when:

  • The value needs to persist across renders
  • Changing it shouldn't trigger a re-render
  • You need to store IDs for cleanup (timers, subscriptions)
  • You're tracking component mount status
  • You need the latest value of a prop/callback in an effect

⚠️ Common Mistake

Don't use refs to avoid re-renders when state is the right choice:

// ❌ Bad - using ref when state is needed
const countRef = useRef(0);
return <p>{countRef.current}</p>; // Won't update!

// ✅ Good - use state for UI
const [count, setCount] = useState(0);
return <p>{count}</p>; // Updates correctly

🎨 Common Ref Patterns

Let's explore the most common and useful patterns for using refs in real applications. These patterns solve everyday problems you'll encounter.

Pattern 1: Focus Management

One of the most common uses of refs is managing focus - automatically focusing inputs, moving focus between fields, or focusing on errors.

🎯 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 {
    // React 19: pass an explicit initial value (undefined)
    const ref = useRef<T | undefined>(undefined);

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

    return ref.current;
}

// Usage example
function CounterWithHistory() {
    const [count, setCount] = useState(0);
    const previousCount = usePrevious(count);
    
    return (
        <div>
            <p>Current: {count}</p>
            <p>Previous: {previousCount ?? 'N/A'}</p>
            <p>
                {previousCount !== undefined && count > previousCount && '📈 Increased!'}
                {previousCount !== undefined && count < previousCount && '📉 Decreased!'}
            </p>
            <button onClick={() => setCount(count + 1)}>+</button>
            <button onClick={() => setCount(count - 1)}>-</button>
        </div>
    );
}

Pattern 6: Imperative Handles with Videos/Audio

Media elements often need imperative control - play, pause, seek. Refs make this straightforward.

interface VideoPlayerProps {
    src: string;
    poster?: string;
}

function VideoPlayer({ src, poster }: VideoPlayerProps) {
    const videoRef = useRef<HTMLVideoElement>(null);
    const [isPlaying, setIsPlaying] = useState(false);
    const [currentTime, setCurrentTime] = useState(0);
    const [duration, setDuration] = useState(0);
    
    const togglePlayPause = () => {
        if (!videoRef.current) return;
        
        if (isPlaying) {
            videoRef.current.pause();
        } else {
            videoRef.current.play();
        }
        setIsPlaying(!isPlaying);
    };
    
    const handleSeek = (time: number) => {
        if (!videoRef.current) return;
        videoRef.current.currentTime = time;
        setCurrentTime(time);
    };
    
    const handleTimeUpdate = () => {
        if (!videoRef.current) return;
        setCurrentTime(videoRef.current.currentTime);
    };
    
    const handleLoadedMetadata = () => {
        if (!videoRef.current) return;
        setDuration(videoRef.current.duration);
    };
    
    const handleSkip = (seconds: number) => {
        if (!videoRef.current) return;
        const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
        handleSeek(newTime);
    };
    
    return (
        <div className="video-player">
            <video
                ref={videoRef}
                src={src}
                poster={poster}
                onTimeUpdate={handleTimeUpdate}
                onLoadedMetadata={handleLoadedMetadata}
                onEnded={() => setIsPlaying(false)}
            />
            
            <div className="controls">
                <button onClick={togglePlayPause}>
                    {isPlaying ? '⏸️ Pause' : '▶️ Play'}
                </button>
                
                <button onClick={() => handleSkip(-10)}>⏪ -10s</button>
                <button onClick={() => handleSkip(10)}>⏩ +10s</button>
                
                <input
                    type="range"
                    min="0"
                    max={duration}
                    value={currentTime}
                    onChange={(e) => handleSeek(Number(e.target.value))}
                    aria-label="Seek video"
                />
                
                <span>
                    {Math.floor(currentTime)}s / {Math.floor(duration)}s
                </span>
            </div>
        </div>
    );
}

🔄 Passing Refs to Child Components

Reusable components — a styled <Input>, a <VideoPlayer>, a <Modal> — often need to let their parent reach the real element inside them, so the parent can focus it, scroll it, or play it. This is where a lot of older tutorials get outdated fast, so let's anchor on the current reality first.

💡 React 19 changed this for the better

In React 19, ref is just a regular prop. A function component can name ref in its props and pass it straight through to a child element — no wrapper, no special API. The forwardRef function you'll see all over the internet is now legacy: still supported for backward compatibility, but no longer necessary for new code. We'll teach the modern pattern first, then show forwardRef so you can recognize it in older codebases.

The Old Gotcha (React 18 and earlier)

Historically, ref was not a normal prop. If you wrote a component that accepted ref in its props, React would intercept it and your child never received it:

// ⚠️ React 18 and earlier: this did NOT work.
//    React reserved `ref`, so `ref` here was always undefined.
function CustomInput({ label, ref }: { label: string; ref: any }) {
    return (
        <div>
            <label>{label}</label>
            <input ref={ref} /> {/* ref never arrived */}
        </div>
    );
}

The Modern Way: ref as a Prop

In React 19 that exact idea now works — you just type it properly. Declare ref in your props interface with React.Ref<T> and forward it to the element:

interface CustomInputProps {
    label: string;
    error?: string;
    ref?: React.Ref<HTMLInputElement>;
}

// ✅ React 19: ref is a normal prop — no forwardRef needed
function CustomInput({ label, error, ref }: CustomInputProps) {
    return (
        <div className="input-group">
            <label>{label}</label>
            <input
                ref={ref}
                className={error ? 'input-error' : ''}
                aria-invalid={!!error}
            />
            {error && <span className="error-message">{error}</span>}
        </div>
    );
}

// Usage — identical to using a built-in element
function LoginForm() {
    const emailRef = useRef<HTMLInputElement>(null);

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

    return (
        <form>
            <CustomInput ref={emailRef} label="Email" />
        </form>
    );
}

🕰️ Legacy pattern: forwardRef

You will still meet forwardRef constantly in existing code and libraries, so recognize it. It wraps your component and delivers the ref as a second argument:

import { forwardRef } from 'react';

// Legacy (React 18) — equivalent to the modern example above
const CustomInput = forwardRef<HTMLInputElement, { label: string }>(
    ({ label }, ref) => (
        <div>
            <label>{label}</label>
            <input ref={ref} />
        </div>
    )
);
CustomInput.displayName = 'CustomInput';

For new React 19 code, prefer the plain ref prop. Reach for forwardRef only when you must support React 18.

Advanced: useImperativeHandle

Sometimes you don't want to expose the raw DOM element at all — you want to hand the parent a small, deliberate API instead: play(), pause(), reset(). useImperativeHandle lets you decide exactly what ref.current becomes. In React 19 it pairs naturally with the ref-as-prop pattern:

import { useImperativeHandle, useRef } from 'react';

// The API we choose to expose to the parent
interface VideoPlayerHandle {
    play: () => void;
    pause: () => void;
    seek: (time: number) => void;
    getCurrentTime: () => number;
}

interface VideoPlayerProps {
    src: string;
    ref?: React.Ref<VideoPlayerHandle>;
}

// ✅ React 19: ref is a prop; useImperativeHandle shapes what it holds
function VideoPlayer({ src, ref }: VideoPlayerProps) {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
        play() {
            videoRef.current?.play();
        },
        pause() {
            videoRef.current?.pause();
        },
        seek(time: number) {
            if (videoRef.current) {
                videoRef.current.currentTime = time;
            }
        },
        getCurrentTime() {
            return videoRef.current?.currentTime ?? 0;
        }
    }), []);

    return <video ref={videoRef} src={src} />;
}

// Usage in parent — ref.current is the handle, not the <video>
function VideoApp() {
    const playerRef = useRef<VideoPlayerHandle>(null);

    return (
        <div>
            <VideoPlayer ref={playerRef} src="video.mp4" />

            <button onClick={() => playerRef.current?.play()}>Play</button>
            <button onClick={() => playerRef.current?.pause()}>Pause</button>
            <button onClick={() => playerRef.current?.seek(30)}>Skip to 30s</button>
        </div>
    );
}

✅ When to Use useImperativeHandle

  • Creating library-like components with specific APIs
  • Hiding implementation details from parent
  • Providing controlled access to child functionality
  • Building form components with validate/reset methods
  • Creating media player components with playback controls

Real-World Example: Form Field Component

interface FormFieldHandle {
    focus: () => void;
    getValue: () => string;
    setValue: (value: string) => void;
    validate: () => boolean;
}

interface FormFieldProps {
    label: string;
    type?: 'text' | 'email' | 'password';
    required?: boolean;
    pattern?: string;
    errorMessage?: string;
    ref?: React.Ref<FormFieldHandle>;
}

// ✅ React 19: ref as a prop + useImperativeHandle for a clean API
function FormField({
    label, type = 'text', required, pattern, errorMessage, ref
}: FormFieldProps) {
    const inputRef = useRef<HTMLInputElement>(null);
    const [error, setError] = useState('');

    useImperativeHandle(ref, () => ({
        focus() {
            inputRef.current?.focus();
        },
        getValue() {
            return inputRef.current?.value ?? '';
        },
        setValue(value: string) {
            if (inputRef.current) {
                inputRef.current.value = value;
            }
        },
        validate() {
            const value = inputRef.current?.value ?? '';

            if (required && !value) {
                setError('This field is required');
                return false;
            }

            if (pattern && !new RegExp(pattern).test(value)) {
                setError(errorMessage ?? 'Invalid format');
                return false;
            }

            setError('');
            return true;
        }
    }), [required, pattern, errorMessage]);

    return (
        <div className="form-field">
            <label>{label} {required && <span>*</span>}</label>
            <input
                ref={inputRef}
                type={type}
                aria-required={required}
                aria-invalid={!!error}
            />
            {error && <span className="error">{error}</span>}
        </div>
    );
}

// Usage
function RegistrationForm() {
    const emailRef = useRef<FormFieldHandle>(null);
    const passwordRef = useRef<FormFieldHandle>(null);
    
    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        
        const emailValid = emailRef.current?.validate();
        const passwordValid = passwordRef.current?.validate();
        
        if (!emailValid) {
            emailRef.current?.focus();
            return;
        }
        
        if (!passwordValid) {
            passwordRef.current?.focus();
            return;
        }
        
        // Submit form
        const email = emailRef.current?.getValue();
        const password = passwordRef.current?.getValue();
        console.log('Submitting:', { email, password });
    };
    
    return (
        <form onSubmit={handleSubmit}>
            <FormField
                ref={emailRef}
                label="Email"
                type="email"
                required
                pattern="^[^\s@]+@[^\s@]+\.[^\s@]+$"
                errorMessage="Please enter a valid email"
            />
            
            <FormField
                ref={passwordRef}
                label="Password"
                type="password"
                required
                pattern=".{8,}"
                errorMessage="Password must be at least 8 characters"
            />
            
            <button type="submit">Register</button>
        </form>
    );
}

🏋️ Hands-on Practice

Let's apply what you've learned with practical exercises. Try to complete each one before looking at the solution!

🏋️ Exercise 1: Image Gallery with Keyboard Navigation

Goal: Build an image gallery where arrow keys navigate between images and the current image is always in view.

Requirements:

  • Display a list of images with thumbnails
  • Use Left/Right arrow keys to navigate
  • Automatically scroll selected image into view
  • Highlight the currently selected image
💡 Hint

You'll need:

  • An array of refs (one for each image)
  • State to track current index
  • useEffect to add keyboard event listener
  • scrollIntoView() when index changes
✅ Solution
interface Image {
    id: string;
    url: string;
    alt: string;
}

interface ImageGalleryProps {
    images: Image[];
}

function ImageGallery({ images }: ImageGalleryProps) {
    const [selectedIndex, setSelectedIndex] = useState(0);
    const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
    
    // Initialize refs array
    useEffect(() => {
        imageRefs.current = imageRefs.current.slice(0, images.length);
    }, [images]);
    
    // Keyboard navigation
    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            if (e.key === 'ArrowLeft') {
                setSelectedIndex(prev => 
                    prev > 0 ? prev - 1 : images.length - 1
                );
            } else if (e.key === 'ArrowRight') {
                setSelectedIndex(prev => 
                    prev < images.length - 1 ? prev + 1 : 0
                );
            }
        };
        
        window.addEventListener('keydown', handleKeyDown);
        return () => window.removeEventListener('keydown', handleKeyDown);
    }, [images.length]);
    
    // Scroll selected image into view
    useEffect(() => {
        imageRefs.current[selectedIndex]?.scrollIntoView({
            behavior: 'smooth',
            block: 'nearest',
            inline: 'center'
        });
    }, [selectedIndex]);
    
    return (
        <div className="gallery">
            <div className="thumbnails">
                {images.map((image, index) => (
                    <div
                        key={image.id}
                        ref={el => imageRefs.current[index] = el}
                        className={`thumbnail ${index === selectedIndex ? 'selected' : ''}`}
                        onClick={() => setSelectedIndex(index)}
                        role="button"
                        tabIndex={0}
                        aria-label={`View ${image.alt}`}
                    >
                        <img src={image.url} alt={image.alt} />
                    </div>
                ))}
            </div>
            
            <div className="main-image">
                <img 
                    src={images[selectedIndex].url} 
                    alt={images[selectedIndex].alt} 
                />
                <p>{selectedIndex + 1} / {images.length}</p>
            </div>
            
            <p className="hint">Use ← → keys to navigate</p>
        </div>
    );
}

🏋️ Exercise 2: Click-Outside to Close

Goal: Create a modal or dropdown that closes when you click outside of it.

Requirements:

  • Show/hide a dropdown on button click
  • Close dropdown when clicking outside
  • Don't close when clicking inside the dropdown
  • Clean up event listeners properly
💡 Hint

Use useRef for the dropdown element and useEffect to add a document click listener that checks if the click target is inside the ref.

✅ Solution
function Dropdown() {
    const [isOpen, setIsOpen] = useState(false);
    const dropdownRef = useRef<HTMLDivElement>(null);
    
    useEffect(() => {
        if (!isOpen) return;
        
        const handleClickOutside = (event: MouseEvent) => {
            if (
                dropdownRef.current && 
                !dropdownRef.current.contains(event.target as Node)
            ) {
                setIsOpen(false);
            }
        };
        
        // Add listener after a brief delay to avoid immediate close
        const timeoutId = setTimeout(() => {
            document.addEventListener('mousedown', handleClickOutside);
        }, 0);
        
        return () => {
            clearTimeout(timeoutId);
            document.removeEventListener('mousedown', handleClickOutside);
        };
    }, [isOpen]);
    
    return (
        <div className="dropdown-container" ref={dropdownRef}>
            <button onClick={() => setIsOpen(!isOpen)}>
                Toggle Menu
            </button>
            
            {isOpen && (
                <div className="dropdown-menu">
                    <button>Option 1</button>
                    <button>Option 2</button>
                    <button>Option 3</button>
                </div>
            )}
        </div>
    );
}

🏋️ Exercise 3: Debounced Search with Auto-focus

Goal: Create a search input that auto-focuses on mount and debounces the search query.

Requirements:

  • Auto-focus the input when component mounts
  • Debounce the search (wait 500ms after typing stops)
  • Display search results
  • Clear timeout on unmount
💡 Hint

You'll need a ref for the input element and another ref to store the timeout ID so you can clear it.

✅ Solution
interface SearchResult {
    id: string;
    title: string;
}

function SearchComponent() {
    const [query, setQuery] = useState('');
    const [results, setResults] = useState<SearchResult[]>([]);
    const [isSearching, setIsSearching] = useState(false);
    const inputRef = useRef<HTMLInputElement>(null);
    const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

    // Auto-focus on mount
    useEffect(() => {
        inputRef.current?.focus();
    }, []);
    
    // Debounced search
    useEffect(() => {
        // Clear previous timeout
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }
        
        if (!query) {
            setResults([]);
            return;
        }
        
        setIsSearching(true);
        
        // Set new timeout
        timeoutRef.current = setTimeout(() => {
            // Simulate API call
            const mockResults: SearchResult[] = [
                { id: '1', title: `Result for "${query}" 1` },
                { id: '2', title: `Result for "${query}" 2` },
                { id: '3', title: `Result for "${query}" 3` }
            ];
            
            setResults(mockResults);
            setIsSearching(false);
        }, 500);
        
        // Cleanup
        return () => {
            if (timeoutRef.current) {
                clearTimeout(timeoutRef.current);
            }
        };
    }, [query]);
    
    return (
        <div className="search-container">
            <input
                ref={inputRef}
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
                aria-label="Search"
            />
            
            {isSearching && <p>Searching...</p>}
            
            <ul className="results">
                {results.map(result => (
                    <li key={result.id}>{result.title}</li>
                ))}
            </ul>
            
            {query && !isSearching && results.length === 0 && (
                <p>No results found</p>
            )}
        </div>
    );
}

🏋️ Challenge: Infinite Scroll

Goal: Implement infinite scroll that loads more items when user scrolls near the bottom.

Requirements:

  • Display a list of items
  • Detect when user scrolls near bottom (within 100px)
  • Load more items automatically
  • Show loading indicator
  • Prevent multiple simultaneous loads
✅ Solution
interface Item {
    id: number;
    title: string;
}

function InfiniteScrollList() {
    const [items, setItems] = useState<Item[]>(
        Array.from({ length: 20 }, (_, i) => ({
            id: i,
            title: `Item ${i + 1}`
        }))
    );
    const [isLoading, setIsLoading] = useState(false);
    const [hasMore, setHasMore] = useState(true);
    const containerRef = useRef<HTMLDivElement>(null);
    const loadingRef = useRef(false);
    
    const loadMore = async () => {
        if (loadingRef.current || !hasMore) return;
        
        loadingRef.current = true;
        setIsLoading(true);
        
        // Simulate API call
        await new Promise(resolve => setTimeout(resolve, 1000));
        
        const newItems = Array.from({ length: 20 }, (_, i) => ({
            id: items.length + i,
            title: `Item ${items.length + i + 1}`
        }));
        
        setItems(prev => [...prev, ...newItems]);
        setIsLoading(false);
        loadingRef.current = false;
        
        // Stop after 100 items
        if (items.length >= 80) {
            setHasMore(false);
        }
    };
    
    useEffect(() => {
        const container = containerRef.current;
        if (!container) return;
        
        const handleScroll = () => {
            const { scrollTop, scrollHeight, clientHeight } = container;
            
            // Load more when within 100px of bottom
            if (scrollHeight - scrollTop - clientHeight < 100) {
                loadMore();
            }
        };
        
        container.addEventListener('scroll', handleScroll);
        return () => container.removeEventListener('scroll', handleScroll);
    }, [items, hasMore]);
    
    return (
        <div 
            ref={containerRef} 
            className="scroll-container"
            style={{ height: '500px', overflow: 'auto' }}
        >
            <h2>Infinite Scroll Demo</h2>
            <ul>
                {items.map(item => (
                    <li key={item.id} style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
                        {item.title}
                    </li>
                ))}
            </ul>
            
            {isLoading && (
                <div style={{ textAlign: 'center', padding: '1rem' }}>
                    Loading more items...
                </div>
            )}
            
            {!hasMore && (
                <div style={{ textAlign: 'center', padding: '1rem' }}>
                    No more items to load
                </div>
            )}
        </div>
    );
}

✅ Best Practices

Follow these guidelines to use refs effectively and avoid common pitfalls.

✅ Do's

  • Use refs for DOM access: Focus inputs, measure elements, scroll positions - these are perfect ref use cases
  • Store mutable values that don't affect rendering: Timer IDs, subscription objects, previous values
  • Always check for null: Use optional chaining (ref.current?.) since refs can be null
  • Clean up in useEffect: Clear intervals, close connections, destroy library instances
  • Use TypeScript generics: useRef<HTMLInputElement>(null) for proper typing
  • Initialize DOM refs with null: useRef<HTMLElement>(null) is the convention
  • Accept ref as a prop for reusable components (React 19): type it as React.Ref<T> — no forwardRef needed for new code
  • Expose a minimal handle with useImperativeHandle: give parents a small API instead of the raw DOM node
  • Combine with other hooks: Refs work great with useState, useEffect, useCallback
  • Document imperative APIs: When using useImperativeHandle, document what methods are exposed

❌ Don'ts

  • Don't use refs instead of state for rendering: If it affects what the user sees, it should be state
  • Don't read/write refs during rendering: Refs should be accessed in event handlers or effects, not during render
  • Don't forget cleanup: Always clear intervals, timeouts, and subscriptions stored in refs
  • Don't mutate ref.current during render: This can cause confusing bugs and inconsistent renders
  • Don't use refs to pass data between components: Use props or context instead
  • Don't expose entire DOM nodes unnecessarily: Use useImperativeHandle to expose only what's needed
  • Don't forget about accessibility: Just because you can control focus doesn't mean you should abuse it
  • Don't use refs for derived values: Calculate them during render instead
  • Don't create refs in loops without keys: Use useRef inside map callbacks carefully
  • Don't rely on ref updates for re-renders: Changing ref.current doesn't trigger re-renders

💡 Pro Tips

  • Create custom hooks with refs: Encapsulate common ref patterns like usePrevious, useClickOutside
  • Use callback refs for dynamic lists: When you need refs to array items, callback refs are more flexible
  • Combine refs with Intersection Observer: Great for lazy loading and infinite scroll
  • Store instances of classes in refs: Perfect for third-party library instances
  • Use refs to prevent stale closures: Store latest values in refs when using setInterval
  • Memoize callback refs: Use useCallback for callback refs to avoid unnecessary recreations
  • Debug with ref.current in DevTools: Set a breakpoint and inspect ref values
  • Create ref-based animations: Use refs with requestAnimationFrame for smooth animations
  • Batch DOM operations with refs: Make multiple DOM changes efficiently outside React's render cycle

🎯 Quick Decision Guide

Scenario Use This Not This
Value displayed to user useState useRef
Timer ID for cleanup useRef useState
Focus an input useRef document.querySelector
Passing data down Props/Context useRef
Tracking previous value useRef useState

⚠️ Common Mistakes to Avoid

Learn from these common mistakes so you don't have to make them yourself!

Mistake 1: Reading Refs During Render

// ❌ Bad - reading ref during render
function BadComponent() {
    const countRef = useRef(0);
    countRef.current++; // Don't do this!
    
    return <div>Renders: {countRef.current}</div>;
}

// ✅ Good - use state for values shown to user
function GoodComponent() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        setCount(prev => prev + 1);
    });
    
    return <div>Renders: {count}</div>;
}

Mistake 2: Expecting Refs to Trigger Re-renders

// ❌ Bad - expecting ref change to update UI
function BadCounter() {
    const countRef = useRef(0);
    
    const increment = () => {
        countRef.current++; // UI won't update!
    };
    
    return (
        <div>
            <p>Count: {countRef.current}</p> {/* Stuck at 0 */}
            <button onClick={increment}>+</button>
        </div>
    );
}

// ✅ Good - use state for UI updates
function GoodCounter() {
    const [count, setCount] = useState(0);
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>+</button>
        </div>
    );
}

Mistake 3: Forgetting Cleanup

// ❌ Bad - no cleanup, memory leak!
function BadTimer() {
    const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

    useEffect(() => {
        timerRef.current = setInterval(() => {
            console.log('tick');
        }, 1000);
        // Missing cleanup!
    }, []);

    return <div>Timer running</div>;
}

// ✅ Good - proper cleanup
function GoodTimer() {
    const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

    useEffect(() => {
        timerRef.current = setInterval(() => {
            console.log('tick');
        }, 1000);
        
        return () => {
            if (timerRef.current) {
                clearInterval(timerRef.current);
            }
        };
    }, []);
    
    return <div>Timer running</div>;
}

Mistake 4: Not Checking for Null

// ❌ Bad - might crash!
function BadFocus() {
    const inputRef = useRef<HTMLInputElement>(null);
    
    useEffect(() => {
        inputRef.current.focus(); // Might be null!
    }, []);
    
    return <input ref={inputRef} />;
}

// ✅ Good - null check
function GoodFocus() {
    const inputRef = useRef<HTMLInputElement>(null);
    
    useEffect(() => {
        inputRef.current?.focus(); // Safe with optional chaining
    }, []);
    
    return <input ref={inputRef} />;
}

Mistake 5: Using Wrong Initial Value

// ❌ Bad - missing initial argument
function BadRef() {
    const inputRef = useRef<HTMLInputElement>(); // Type error in React 19!
    return <input ref={inputRef} />;
}

// ❌ Also bad - mutable value with no initial value
function AlsoBad() {
    const countRef = useRef<number>(); // Type error — and would be undefined
    countRef.current++; // NaN!
}

// ✅ Good - proper initialization
function GoodRefs() {
    const inputRef = useRef<HTMLInputElement>(null); // DOM refs start as null
    const countRef = useRef(0); // Mutable values have initial value
    
    return <input ref={inputRef} />;
}

Mistake 6: Stale Closures with Refs

// ❌ Bad - stale closure captures old value
function BadCallback({ onSave }: { onSave: (data: string) => void }) {
    const [data, setData] = useState('');
    
    useEffect(() => {
        const interval = setInterval(() => {
            onSave(data); // Always uses initial data value!
        }, 1000);
        
        return () => clearInterval(interval);
    }, []); // Empty deps = stale closure
    
    return <input value={data} onChange={e => setData(e.target.value)} />;
}

// ✅ Good - ref always has latest value
function GoodCallback({ onSave }: { onSave: (data: string) => void }) {
    const [data, setData] = useState('');
    const dataRef = useRef(data);
    
    // Keep ref in sync
    useEffect(() => {
        dataRef.current = data;
    }, [data]);
    
    useEffect(() => {
        const interval = setInterval(() => {
            onSave(dataRef.current); // Always uses latest data!
        }, 1000);
        
        return () => clearInterval(interval);
    }, [onSave]);
    
    return <input value={data} onChange={e => setData(e.target.value)} />;
}

⚠️ Remember

  • Refs are for DOM access and mutable values, not UI state
  • Changing refs doesn't trigger re-renders
  • Always clean up subscriptions, timers, and connections
  • Use optional chaining with refs to avoid crashes
  • Initialize DOM refs with null, mutable values with actual values
  • Use refs to solve stale closure problems in effects

📚 Summary

🎉 Key Takeaways

  • useRef creates a mutable container that persists across renders without triggering re-renders
  • Two main use cases: accessing DOM elements and storing mutable values
  • Refs vs State: Use state for UI, refs for everything else that needs to persist
  • Always check for null when accessing refs (use optional chaining)
  • TypeScript typing: useRef<Type>(initialValue) for proper type safety
  • Common patterns: Focus management, scroll control, measuring elements, integrating libraries
  • In React 19, ref is a normal prop — pass refs to child components directly; forwardRef is legacy
  • useImperativeHandle customizes what parent components can access via the ref
  • Clean up subscriptions stored in refs to prevent memory leaks
  • Don't read/write refs during render - use effects or event handlers instead

🎯 When to Use useRef

Use Case Example
DOM Access Focus inputs, scroll to elements, measure sizes
Timers Store setInterval/setTimeout IDs for cleanup
Previous Values Track what props/state was in last render
Library Integration Store Chart.js instances, WebSocket connections
Avoiding Re-renders Store values that don't affect UI
Mutable Trackers Count renders, track component mount status

🔄 The Complete Picture

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 a draft to localStorage every 5 seconds using refs
  • Notification System: Create toast notifications that auto-dismiss and can be manually closed

🎉 Congratulations!

You've mastered one of React's most versatile hooks! You now understand how to work with the DOM imperatively, store mutable values that survive renders, pass refs to child components the modern React 19 way, and sidestep the common pitfalls. Refs are your secret weapon for the moments when React's declarative approach needs a little imperative help. Keep practicing and you'll find them invaluable in your React toolkit! 🚀