Reactintermediate

useRef: Accessing DOM & Storing Values

Master React's useRef hook: DOM access, persisting values across renders, forwardRef, imperative patterns, instance variables, and practical custom hooks. Complete guide with working examples.

12 min readยทPublished Mar 17, 2026
reacthooksuserefdom

What Is useRef?

useRef is a React hook that returns a mutable object with a .current property. Unlike state, changing .current does not trigger a re-render. This makes it perfect for two things:

  1. Accessing DOM elements directly
  2. Storing mutable values that persist across renders without causing re-renders
import { useRef } from 'react';

function TextInput() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus();  // Direct DOM access
  };

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="Click the button..." />
      <button onClick={focusInput}>Focus Input</button>
    </div>
  );
}

useRef vs useState

This is the most important distinction to understand.

function RefVsState() {
  const [stateCount, setStateCount] = useState(0);
  const refCount = useRef(0);

  const incrementState = () => {
    setStateCount(s => s + 1);
    // Triggers re-render, UI updates
  };

  const incrementRef = () => {
    refCount.current += 1;
    // NO re-render, UI does NOT update
    console.log('Ref value:', refCount.current);
  };

  return (
    <div>
      <p>State: {stateCount}</p>
      <p>Ref: {refCount.current}</p>  {/* Shows stale value until re-render */}
      <button onClick={incrementState}>+1 State</button>
      <button onClick={incrementRef}>+1 Ref (check console)</button>
    </div>
  );
}
useStateuseRef
Triggers re-renderYesNo
Returns[value, setter]{ current: value }
PersistenceAcross rendersAcross renders
Update methodsetValue(newValue)ref.current = newValue
Async accessClosure captures value from that renderAlways latest value
Use forData the UI depends onDOM access, timers, previous values
useState flow:
  setValue(5) -> React schedules re-render -> component runs -> reads new value

useRef flow:
  ref.current = 5 -> Nothing happens (no render)
  Next render (triggered by something else) -> ref.current is still 5

DOM Access with Refs

Basic DOM Ref

function VideoPlayer({ src }) {
  const videoRef = useRef(null);

  const play = () => videoRef.current.play();
  const pause = () => videoRef.current.pause();
  const restart = () => {
    videoRef.current.currentTime = 0;
    videoRef.current.play();
  };

  return (
    <div>
      <video ref={videoRef} src={src} />
      <div>
        <button onClick={play}>Play</button>
        <button onClick={pause}>Pause</button>
        <button onClick={restart}>Restart</button>
      </div>
    </div>
  );
}

Scrolling to an Element

function ScrollToSection() {
  const section1Ref = useRef(null);
  const section2Ref = useRef(null);
  const section3Ref = useRef(null);

  const scrollTo = (ref) => {
    ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
  };

  return (
    <div>
      <nav>
        <button onClick={() => scrollTo(section1Ref)}>Section 1</button>
        <button onClick={() => scrollTo(section2Ref)}>Section 2</button>
        <button onClick={() => scrollTo(section3Ref)}>Section 3</button>
      </nav>

      <section ref={section1Ref} style={{ height: '100vh' }}>
        <h2>Section 1</h2>
      </section>
      <section ref={section2Ref} style={{ height: '100vh' }}>
        <h2>Section 2</h2>
      </section>
      <section ref={section3Ref} style={{ height: '100vh' }}>
        <h2>Section 3</h2>
      </section>
    </div>
  );
}

Measuring DOM Elements

function MeasuredBox() {
  const boxRef = useRef(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (boxRef.current) {
      const { width, height } = boxRef.current.getBoundingClientRect();
      setDimensions({ width: Math.round(width), height: Math.round(height) });
    }
  }, []);

  return (
    <div>
      <div
        ref={boxRef}
        style={{ padding: 20, border: '1px solid #ccc', display: 'inline-block' }}
      >
        This box has content of varying size.
      </div>
      <p>Width: {dimensions.width}px, Height: {dimensions.height}px</p>
    </div>
  );
}

Canvas Drawing

function DrawingCanvas() {
  const canvasRef = useRef(null);
  const isDrawing = useRef(false);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    ctx.strokeStyle = '#3b82f6';
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';

    const startDraw = (e) => {
      isDrawing.current = true;
      ctx.beginPath();
      ctx.moveTo(e.offsetX, e.offsetY);
    };

    const draw = (e) => {
      if (!isDrawing.current) return;
      ctx.lineTo(e.offsetX, e.offsetY);
      ctx.stroke();
    };

    const stopDraw = () => {
      isDrawing.current = false;
    };

    canvas.addEventListener('mousedown', startDraw);
    canvas.addEventListener('mousemove', draw);
    canvas.addEventListener('mouseup', stopDraw);
    canvas.addEventListener('mouseleave', stopDraw);

    return () => {
      canvas.removeEventListener('mousedown', startDraw);
      canvas.removeEventListener('mousemove', draw);
      canvas.removeEventListener('mouseup', stopDraw);
      canvas.removeEventListener('mouseleave', stopDraw);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      width={400}
      height={300}
      style={{ border: '1px solid #ccc', cursor: 'crosshair' }}
    />
  );
}

useRef for Instance Variables

Refs are perfect for storing values that need to persist across renders but shouldn't trigger re-renders. Think of them as "instance variables" for function components.

Tracking Previous Value

function usePrevious(value) {
  const ref = useRef();

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

  return ref.current;  // Returns previous value (before this render's effect runs)
}

// Usage
function PriceDisplay({ price }) {
  const prevPrice = usePrevious(price);
  const direction = prevPrice !== undefined
    ? price > prevPrice ? 'up' : price < prevPrice ? 'down' : 'same'
    : 'same';

  return (
    <div>
      <span className={`price price-${direction}`}>
        ${price.toFixed(2)}
      </span>
      {prevPrice !== undefined && (
        <span className="price-change">
          (was ${prevPrice.toFixed(2)})
        </span>
      )}
    </div>
  );
}
How usePrevious works:

  Render 1: value=10
    ref.current = undefined  (initial)
    return undefined         (no previous value)
    After render: ref.current = 10

  Render 2: value=20
    ref.current = 10         (set in previous render's effect)
    return 10                (previous value!)
    After render: ref.current = 20

  Render 3: value=15
    ref.current = 20
    return 20
    After render: ref.current = 15

Storing Timer IDs

function Debouncer() {
  const [value, setValue] = useState('');
  const [debouncedValue, setDebouncedValue] = useState('');
  const timerRef = useRef(null);

  const handleChange = (e) => {
    const newValue = e.target.value;
    setValue(newValue);

    // Clear previous timer
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }

    // Set new timer
    timerRef.current = setTimeout(() => {
      setDebouncedValue(newValue);
    }, 500);
  };

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  return (
    <div>
      <input value={value} onChange={handleChange} />
      <p>Debounced: {debouncedValue}</p>
    </div>
  );
}

Counting Renders (Debugging)

function RenderCounter({ children }) {
  const renderCount = useRef(0);
  renderCount.current += 1;

  return (
    <div>
      <div style={{ fontSize: 12, color: '#888' }}>
        Renders: {renderCount.current}
      </div>
      {children}
    </div>
  );
}

Storing Latest Callback (Avoiding Stale Closures)

function useLatestCallback(callback) {
  const ref = useRef(callback);
  ref.current = callback;  // Always points to latest version

  // Return a stable function that calls the latest callback
  return useCallback((...args) => ref.current(...args), []);
}

// Usage: event handlers that don't need to be in useEffect deps
function Search({ onResults }) {
  const stableOnResults = useLatestCallback(onResults);

  useEffect(() => {
    const ws = new WebSocket('wss://api.example.com/search');
    ws.onmessage = (e) => {
      stableOnResults(JSON.parse(e.data));
    };
    return () => ws.close();
  }, [stableOnResults]);  // stableOnResults never changes
}

forwardRef: Passing Refs to Child Components

By default, refs only work on DOM elements, not custom components. forwardRef lets a component receive a ref from its parent and forward it to a DOM element inside.

The Problem

// This does NOT work โ€” ref on a custom component doesn't attach to anything
function Parent() {
  const inputRef = useRef(null);

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>Focus</button>
      {/* ERROR: inputRef.current is null */}
    </div>
  );
}

function CustomInput(props) {
  return <input {...props} className="custom-input" />;
}

The Solution: forwardRef

import { forwardRef, useRef } from 'react';

const CustomInput = forwardRef(function CustomInput({ label, ...props }, ref) {
  return (
    <div className="input-group">
      {label && <label>{label}</label>}
      <input ref={ref} {...props} className="custom-input" />
    </div>
  );
});

function Parent() {
  const inputRef = useRef(null);

  return (
    <div>
      <CustomInput ref={inputRef} label="Username" placeholder="Enter name" />
      <button onClick={() => inputRef.current.focus()}>Focus</button>
    </div>
  );
}

forwardRef with TypeScript

type InputProps = {
  label?: string;
  error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;

const FormInput = forwardRef<HTMLInputElement, InputProps>(
  function FormInput({ label, error, ...props }, ref) {
    return (
      <div>
        {label && <label>{label}</label>}
        <input ref={ref} {...props} />
        {error && <p className="error">{error}</p>}
      </div>
    );
  }
);

useImperativeHandle: Controlling Exposed API

Sometimes you want to expose a limited API to the parent instead of the full DOM element. useImperativeHandle customizes what the ref exposes.

import { forwardRef, useImperativeHandle, useRef, useState } from 'react';

const Timer = forwardRef(function Timer(props, ref) {
  const [time, setTime] = useState(0);
  const intervalRef = useRef(null);

  useImperativeHandle(ref, () => ({
    start() {
      if (intervalRef.current) return;
      intervalRef.current = setInterval(() => {
        setTime(t => t + 1);
      }, 1000);
    },
    stop() {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    },
    reset() {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
      setTime(0);
    },
    getTime() {
      return time;
    },
  }), [time]);

  return <p>{time}s</p>;
});

// Parent only sees start/stop/reset/getTime โ€” NOT the DOM element
function App() {
  const timerRef = useRef(null);

  return (
    <div>
      <Timer ref={timerRef} />
      <button onClick={() => timerRef.current.start()}>Start</button>
      <button onClick={() => timerRef.current.stop()}>Stop</button>
      <button onClick={() => timerRef.current.reset()}>Reset</button>
    </div>
  );
}
Without useImperativeHandle:
  ref.current = <p> DOM element
  Parent can do: ref.current.innerHTML, ref.current.style, etc.
  (Too much access โ€” breaks encapsulation)

With useImperativeHandle:
  ref.current = { start, stop, reset, getTime }
  Parent can only use the methods you expose
  (Clean API โ€” component controls what's accessible)

Callback Refs

For cases where you need to know when a ref is attached or detached, use a callback ref instead of useRef.

function MeasureOnMount() {
  const [height, setHeight] = useState(0);

  // Callback ref โ€” called when element mounts/unmounts
  const measuredRef = useCallback((node) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <div>
      <div ref={measuredRef} style={{ padding: 20 }}>
        <p>Line 1</p>
        <p>Line 2</p>
        <p>Line 3</p>
      </div>
      <p>Measured height: {height}px</p>
    </div>
  );
}

When to Use Callback Refs vs useRef

useRefCallback ref
Syntaxconst ref = useRef(null)const ref = useCallback(node => {...}, [])
NotificationNone when attachedCalled when attached/detached
Use caseAccess element after mountMeasure on mount, conditional elements
UpdatesManualAutomatic when element appears/disappears

When Refs Are Appropriate

Good Uses

  1. Managing focus โ€” inputRef.current.focus()
  2. Triggering animations โ€” elementRef.current.animate(...)
  3. Integrating with third-party DOM libraries โ€” D3, chart libraries, map libraries
  4. Storing timer/interval IDs โ€” no re-render needed
  5. Storing previous props/state โ€” usePrevious pattern
  6. Storing latest callback โ€” avoiding stale closures in intervals/subscriptions
  7. Measuring elements โ€” getBoundingClientRect()
  8. Controlling media elements โ€” play, pause, seek
  9. Scrolling โ€” scrollIntoView()

Bad Uses (Use State Instead)

  1. Storing data the UI depends on โ€” use useState
  2. Tracking form values โ€” use controlled components
  3. Manually updating DOM text/content โ€” let React handle it
  4. Hiding/showing elements โ€” use conditional rendering
  5. Adding/removing CSS classes โ€” use state-driven className
// BAD โ€” imperatively updating DOM (fighting React)
function Bad() {
  const pRef = useRef(null);

  const updateText = () => {
    pRef.current.textContent = 'Updated!';  // React doesn't know about this
    pRef.current.style.color = 'red';
  };

  return <p ref={pRef}>Original</p>;
}

// GOOD โ€” let React manage the DOM
function Good() {
  const [text, setText] = useState('Original');
  const [isHighlighted, setIsHighlighted] = useState(false);

  const update = () => {
    setText('Updated!');
    setIsHighlighted(true);
  };

  return <p style={{ color: isHighlighted ? 'red' : 'inherit' }}>{text}</p>;
}

useRef in Custom Hooks

useClickOutside

function useClickOutside(handler) {
  const ref = useRef(null);

  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) return;
      handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [handler]);

  return ref;
}

// Usage
function Popover({ onClose, children }) {
  const popoverRef = useClickOutside(onClose);

  return (
    <div ref={popoverRef} className="popover">
      {children}
    </div>
  );
}

useHover

function useHover() {
  const ref = useRef(null);
  const [isHovered, setIsHovered] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleEnter = () => setIsHovered(true);
    const handleLeave = () => setIsHovered(false);

    element.addEventListener('mouseenter', handleEnter);
    element.addEventListener('mouseleave', handleLeave);

    return () => {
      element.removeEventListener('mouseenter', handleEnter);
      element.removeEventListener('mouseleave', handleLeave);
    };
  }, []);

  return [ref, isHovered];
}

// Usage
function HoverCard() {
  const [cardRef, isHovered] = useHover();

  return (
    <div
      ref={cardRef}
      style={{
        padding: 20,
        background: isHovered ? '#f0f0f0' : '#fff',
        transition: 'background 200ms',
      }}
    >
      {isHovered ? 'Hovering!' : 'Hover me'}
    </div>
  );
}

useIntersectionObserver

function useIntersectionObserver(options = {}) {
  const ref = useRef(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);

    observer.observe(element);
    return () => observer.disconnect();
  }, [options.threshold, options.rootMargin]);

  return [ref, isIntersecting];
}

// Usage: Lazy loading
function LazySection({ children }) {
  const [ref, isVisible] = useIntersectionObserver({ threshold: 0.1 });

  return (
    <div ref={ref}>
      {isVisible ? children : <div style={{ height: 200 }}>Loading...</div>}
    </div>
  );
}

useFocusTrap

function useFocusTrap() {
  const ref = useRef(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const focusableElements = element.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    const handleKeyDown = (e) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable.focus();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable.focus();
        }
      }
    };

    element.addEventListener('keydown', handleKeyDown);
    firstFocusable?.focus();

    return () => element.removeEventListener('keydown', handleKeyDown);
  }, []);

  return ref;
}

// Usage: Modal with focus trap
function Modal({ onClose, children }) {
  const trapRef = useFocusTrap();

  return (
    <div className="modal-overlay">
      <div ref={trapRef} className="modal" role="dialog" aria-modal="true">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Key Takeaways

  1. useRef returns { current: value } โ€” persists across renders, changes don't trigger re-renders
  2. DOM access โ€” attach ref to an element with the ref prop, then use ref.current to access the DOM node
  3. Instance variables โ€” store timer IDs, previous values, latest callbacks, and any mutable value that doesn't affect the UI
  4. forwardRef โ€” lets parent components access DOM elements inside child components
  5. useImperativeHandle โ€” limits what the parent can access through the ref (clean API)
  6. Callback refs โ€” use when you need to know when an element mounts/unmounts
  7. Don't use refs for things React should manage โ€” text content, visibility, styling
  8. Refs always hold the latest value โ€” no stale closure problem like with state
  9. Modifying ref.current during render is allowed but should be idempotent (same result if called twice, for Strict Mode)
  10. Custom hooks use refs heavily โ€” useClickOutside, useHover, usePrevious, useIntersectionObserver

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles