Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
16 minLesson 10 of 33
Advanced React Patterns

useRef & Forwarding Refs

useRef & Forwarding Refs

useRef serves two distinct purposes that look unrelated at first: accessing DOM elements directly, and storing mutable values that persist between renders without causing re-renders. Both are important tools.

DOM Access

The most common use: getting a direct reference to a DOM node so you can call native DOM methods on it.

import { useRef, useEffect } from "react";

// Auto-focus an input on mount
function SearchModal({ isOpen }: { isOpen: boolean }) {
    const inputRef = useRef<HTMLInputElement>(null);
    
    useEffect(() => {
        if (isOpen) {
            inputRef.current?.focus();
        }
    }, [isOpen]);
    
    return (
        <dialog open={isOpen}>
            <input ref={inputRef} placeholder="Search..." />
        </dialog>
    );
}

After the component mounts, inputRef.current holds the actual <input> DOM element. You can call any native DOM method on it: .focus(), .blur(), .scrollIntoView(), .select(), etc.

Storing Values That Don't Trigger Re-renders

useRef returns a mutable object { current: initialValue }. Changing .current does NOT cause a re-render — this is the key difference from useState.

// Tracking the previous value of a prop
function PriceDisplay({ price }: { price: number }) {
    const prevPriceRef = useRef<number>(price);
    
    useEffect(() => {
        prevPriceRef.current = price;   // update after render
    });
    
    const prevPrice = prevPriceRef.current;
    const changed = prevPrice !== price;
    
    return (
        <div>
            <span className={changed ? (price > prevPrice ? "text-green-600" : "text-red-600") : ""}>
                ${price.toFixed(2)}
            </span>
            {changed && <small>(was ${prevPrice.toFixed(2)})</small>}
        </div>
    );
}
// Tracking a timer ID
function Countdown() {
    const [seconds, setSeconds] = useState(60);
    const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
    
    function start() {
        timerRef.current = setInterval(() => {
            setSeconds(s => {
                if (s <= 1) {
                    clearInterval(timerRef.current!);
                    return 0;
                }
                return s - 1;
            });
        }, 1000);
    }
    
    function stop() {
        clearInterval(timerRef.current!);
    }
    
    return (
        <div>
            <p>{seconds}s remaining</p>
            <button onClick={start}>Start</button>
            <button onClick={stop}>Stop</button>
        </div>
    );
}
// Preventing the initial useEffect run (common pattern)
function DataFetcher({ userId }: { userId: string }) {
    const [data, setData] = useState(null);
    const isMountRef = useRef(false);
    
    useEffect(() => {
        if (!isMountRef.current) {
            isMountRef.current = true;
            return;   // skip on first render
        }
        
        fetchUser(userId).then(setData);
    }, [userId]);
    
    // ...
}

Debouncing with useRef

Store the timer ID in a ref so it persists between renders:

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
    const [query, setQuery] = useState("");
    const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
    
    function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
        const value = e.target.value;
        setQuery(value);
        
        // Clear previous timer
        if (debounceTimer.current) clearTimeout(debounceTimer.current);
        
        // Set new timer
        debounceTimer.current = setTimeout(() => {
            onSearch(value);
        }, 300);
    }
    
    // Clean up on unmount
    useEffect(() => {
        return () => {
            if (debounceTimer.current) clearTimeout(debounceTimer.current);
        };
    }, []);
    
    return <input value={query} onChange={handleChange} />;
}

Forwarding Refs

When you build a wrapper component around a native element, consumers might want a ref to the underlying DOM node. forwardRef enables this:

import { forwardRef } from "react";

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
    label: string;
    error?: string;
}

// The ref is passed as the second argument
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
    { label, error, className = "", ...props },
    ref   // <-- the forwarded ref
) {
    return (
        <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
                {label}
            </label>
            <input
                ref={ref}   // attach to the underlying DOM element
                className={`w-full border rounded-lg px-3 py-2 ${error ? "border-red-400" : "border-gray-300"} ${className}`}
                {...props}
            />
            {error && <p className="text-red-600 text-sm mt-1">{error}</p>}
        </div>
    );
});

// Consumer can now use a ref:
function Form() {
    const emailRef = useRef<HTMLInputElement>(null);
    
    function focusEmail() {
        emailRef.current?.focus();
    }
    
    return (
        <form>
            <Input ref={emailRef} label="Email" type="email" />
            <button type="button" onClick={focusEmail}>Focus email</button>
        </form>
    );
}

useImperativeHandle

Sometimes you want to expose a custom API through a ref — not the raw DOM node, but specific methods you control:

import { useRef, forwardRef, useImperativeHandle } from "react";

interface VideoPlayerHandle {
    play: () => void;
    pause: () => void;
    seek: (seconds: number) => void;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(function VideoPlayer(
    { src },
    ref
) {
    const videoRef = useRef<HTMLVideoElement>(null);
    
    useImperativeHandle(ref, () => ({
        play: () => videoRef.current?.play(),
        pause: () => videoRef.current?.pause(),
        seek: (seconds) => {
            if (videoRef.current) videoRef.current.currentTime = seconds;
        },
    }), []);
    
    return <video ref={videoRef} src={src} />;
});

// Consumer gets the custom API, not the raw DOM element
function App() {
    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 0:30</button>
        </div>
    );
}

Key Distinctions

useRef vs useState:
- Both persist between renders
- useRef changes do NOT trigger re-renders
- useState changes DO trigger re-renders
- Use useRef for values that are implementation details
- Use useState for values that should be reflected in the UI

useRef vs a variable in the component body:
- Variables in the component body are recreated on every render
- useRef persists the same object across renders

Next lesson: Custom hooks — extracting and reusing component logic.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!