|$ curl https://forge-ai.dev/api/markdown?path=docs/react/refs
$cat docs/react-—-refs-&-dom.md
updated Last week·15 min read·published
React — Refs & DOM
Introduction
Refs provide a way to access DOM nodes or React elements created in the render method. They are an escape hatch from the declarative React model — use them when you need imperative access: focusing inputs, measuring dimensions, scrolling, or integrating with non-React libraries.
useRef
use_ref.tsx
TSX
| 1 | import { useRef, useEffect } from "react"; |
| 2 | |
| 3 | // useRef returns a mutable object whose .current property persists |
| 4 | // across renders without causing re-renders when changed |
| 5 | |
| 6 | function TextInputWithFocus() { |
| 7 | const inputRef = useRef<HTMLInputElement>(null); |
| 8 | |
| 9 | function handleClick() { |
| 10 | // .current gives access to the DOM node |
| 11 | inputRef.current?.focus(); |
| 12 | } |
| 13 | |
| 14 | return ( |
| 15 | <div> |
| 16 | <input ref={inputRef} type="text" /> |
| 17 | <button onClick={handleClick}>Focus Input</button> |
| 18 | </div> |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | // useRef for storing previous values |
| 23 | function usePrevious<T>(value: T): T | undefined { |
| 24 | const ref = useRef<T>(); |
| 25 | |
| 26 | useEffect(() => { |
| 27 | ref.current = value; |
| 28 | }, [value]); |
| 29 | |
| 30 | return ref.current; |
| 31 | } |
| 32 | |
| 33 | function Counter() { |
| 34 | const [count, setCount] = useState(0); |
| 35 | const prevCount = usePrevious(count); |
| 36 | |
| 37 | return ( |
| 38 | <p> |
| 39 | Now: {count}, Before: {prevCount ?? "N/A"} |
| 40 | </p> |
| 41 | ); |
| 42 | } |
| 43 | |
| 44 | // useRef for storing interval IDs |
| 45 | function Timer() { |
| 46 | const [seconds, setSeconds] = useState(0); |
| 47 | const intervalRef = useRef<NodeJS.Timeout | null>(null); |
| 48 | |
| 49 | function start() { |
| 50 | if (intervalRef.current) return; |
| 51 | intervalRef.current = setInterval(() => { |
| 52 | setSeconds((s) => s + 1); |
| 53 | }, 1000); |
| 54 | } |
| 55 | |
| 56 | function stop() { |
| 57 | if (intervalRef.current) { |
| 58 | clearInterval(intervalRef.current); |
| 59 | intervalRef.current = null; |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | useEffect(() => { |
| 64 | return () => { |
| 65 | if (intervalRef.current) clearInterval(intervalRef.current); |
| 66 | }; |
| 67 | }, []); |
| 68 | |
| 69 | return ( |
| 70 | <div> |
| 71 | <p>{seconds}s</p> |
| 72 | <button onClick={start}>Start</button> |
| 73 | <button onClick={stop}>Stop</button> |
| 74 | </div> |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | // useRef for storing render count |
| 79 | function RenderCounter() { |
| 80 | const renderCount = useRef(0); |
| 81 | |
| 82 | useEffect(() => { |
| 83 | renderCount.current += 1; |
| 84 | }); |
| 85 | |
| 86 | return <p>Rendered {renderCount.current} times</p>; |
| 87 | } |
ℹ
info
Changing ref.current does not trigger a re-render. This makes refs perfect for storing mutable values that should persist across renders but should not affect the UI.
Ref Callbacks
ref_callbacks.tsx
TSX
| 1 | // Ref callback — function called with the DOM node on mount/unmount |
| 2 | function MeasureExample() { |
| 3 | const [height, setHeight] = useState(0); |
| 4 | |
| 5 | const measuredRef = useCallback((node: HTMLDivElement | null) => { |
| 6 | if (node !== null) { |
| 7 | setHeight(node.getBoundingClientRect().height); |
| 8 | } |
| 9 | }, []); |
| 10 | |
| 11 | return ( |
| 12 | <div> |
| 13 | <p ref={measuredRef}>Hello, world</p> |
| 14 | <p>The above paragraph is {Math.round(height)}px tall</p> |
| 15 | </div> |
| 16 | ); |
| 17 | } |
| 18 | |
| 19 | // Ref callback with cleanup — for subscription patterns |
| 20 | function TrackDimensions() { |
| 21 | const [size, setSize] = useState({ width: 0, height: 0 }); |
| 22 | |
| 23 | const ref = useCallback((node: HTMLDivElement | null) => { |
| 24 | if (!node) return; |
| 25 | |
| 26 | const observer = new ResizeObserver((entries) => { |
| 27 | for (const entry of entries) { |
| 28 | setSize({ |
| 29 | width: entry.contentRect.width, |
| 30 | height: entry.contentRect.height, |
| 31 | }); |
| 32 | } |
| 33 | }); |
| 34 | |
| 35 | observer.observe(node); |
| 36 | |
| 37 | // Cleanup function — called when node unmounts |
| 38 | return () => observer.disconnect(); |
| 39 | }, []); |
| 40 | |
| 41 | return ( |
| 42 | <div ref={ref}> |
| 43 | <p>Width: {size.width}px, Height: {size.height}px</p> |
| 44 | </div> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | // Multiple refs with callback pattern |
| 49 | function MultiRefForm() { |
| 50 | const refs = useRef<Map<string, HTMLInputElement>>(new Map()); |
| 51 | |
| 52 | const setRef = useCallback( |
| 53 | (name: string) => (node: HTMLInputElement | null) => { |
| 54 | if (node) { |
| 55 | refs.current.set(name, node); |
| 56 | } else { |
| 57 | refs.current.delete(name); |
| 58 | } |
| 59 | }, |
| 60 | [] |
| 61 | ); |
| 62 | |
| 63 | function handleSubmit() { |
| 64 | const formData: Record<string, string> = {}; |
| 65 | refs.current.forEach((input, name) => { |
| 66 | formData[name] = input.value; |
| 67 | }); |
| 68 | console.log(formData); |
| 69 | } |
| 70 | |
| 71 | return ( |
| 72 | <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}> |
| 73 | <input ref={setRef("email")} name="email" placeholder="Email" /> |
| 74 | <input ref={setRef("password")} name="password" type="password" /> |
| 75 | <button type="submit">Submit</button> |
| 76 | </form> |
| 77 | ); |
| 78 | } |
forwardRef
forward_ref.tsx
TSX
| 1 | // forwardRef lets a child component expose its DOM node to a parent |
| 2 | |
| 3 | // Without forwardRef — ref prop is not forwarded |
| 4 | // function MyInput(props) { |
| 5 | // return <input {...props} />; // ref is NOT forwarded |
| 6 | // } |
| 7 | |
| 8 | // With forwardRef — ref is forwarded to the inner element |
| 9 | const MyInput = React.forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>( |
| 10 | (props, ref) => { |
| 11 | return <input ref={ref} {...props} className="fancy-input" />; |
| 12 | } |
| 13 | ); |
| 14 | |
| 15 | // Parent usage |
| 16 | function Parent() { |
| 17 | const inputRef = useRef<HTMLInputElement>(null); |
| 18 | |
| 19 | function focus() { |
| 20 | inputRef.current?.focus(); |
| 21 | } |
| 22 | |
| 23 | return ( |
| 24 | <div> |
| 25 | <MyInput ref={inputRef} placeholder="Type here..." /> |
| 26 | <button onClick={focus}>Focus</button> |
| 27 | </div> |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | // forwardRef with imperative handle |
| 32 | interface FormHandle { |
| 33 | focus: () => void; |
| 34 | reset: () => void; |
| 35 | getValues: () => Record<string, string>; |
| 36 | } |
| 37 | |
| 38 | const FancyInput = React.forwardRef<FormHandle, { name: string }>( |
| 39 | ({ name }, ref) => { |
| 40 | const inputRef = useRef<HTMLInputElement>(null); |
| 41 | const [value, setValue] = useState(""); |
| 42 | |
| 43 | useImperativeHandle(ref, () => ({ |
| 44 | focus: () => inputRef.current?.focus(), |
| 45 | reset: () => { |
| 46 | setValue(""); |
| 47 | inputRef.current?.value = ""; |
| 48 | }, |
| 49 | getValues: () => ({ [name]: value }), |
| 50 | })); |
| 51 | |
| 52 | return ( |
| 53 | <input |
| 54 | ref={inputRef} |
| 55 | value={value} |
| 56 | onChange={(e) => setValue(e.target.value)} |
| 57 | placeholder={name} |
| 58 | /> |
| 59 | ); |
| 60 | } |
| 61 | ); |
| 62 | |
| 63 | // Forwarding ref through multiple layers |
| 64 | const InnerInput = React.forwardRef<HTMLInputElement, InputProps>( |
| 65 | (props, ref) => <input ref={ref} {...props} /> |
| 66 | ); |
| 67 | |
| 68 | const OuterWrapper = React.forwardRef<HTMLInputElement, InputProps>( |
| 69 | (props, ref) => ( |
| 70 | <div className="wrapper"> |
| 71 | <InnerInput ref={ref} {...props} /> |
| 72 | </div> |
| 73 | ) |
| 74 | ); |
| 75 | |
| 76 | // In React 19, you can pass ref as a regular prop — no forwardRef needed: |
| 77 | // function MyInput({ ref, ...props }) { |
| 78 | // return <input ref={ref} {...props} />; |
| 79 | // } |
ℹ
info
In React 19, ref is a regular prop — you can pass it without forwardRef. For React 18 and below, use forwardRef to pass refs to child components.
useImperativeHandle
imperative_handle.tsx
TSX
| 1 | // useImperativeHandle customizes the ref value exposed to parents |
| 2 | |
| 3 | interface VideoPlayerHandle { |
| 4 | play: () => void; |
| 5 | pause: () => void; |
| 6 | seek: (time: number) => void; |
| 7 | getDuration: () => number; |
| 8 | } |
| 9 | |
| 10 | const VideoPlayer = React.forwardRef<VideoPlayerHandle, { src: string }>( |
| 11 | ({ src }, ref) => { |
| 12 | const videoRef = useRef<HTMLVideoElement>(null); |
| 13 | |
| 14 | useImperativeHandle(ref, () => ({ |
| 15 | play: () => videoRef.current?.play(), |
| 16 | pause: () => videoRef.current?.pause(), |
| 17 | seek: (time: number) => { |
| 18 | if (videoRef.current) videoRef.current.currentTime = time; |
| 19 | }, |
| 20 | getDuration: () => videoRef.current?.duration ?? 0, |
| 21 | })); |
| 22 | |
| 23 | return <video ref={videoRef} src={src} className="w-full" />; |
| 24 | } |
| 25 | ); |
| 26 | |
| 27 | // Parent controls the player through the ref |
| 28 | function MediaPage() { |
| 29 | const playerRef = useRef<VideoPlayerHandle>(null); |
| 30 | |
| 31 | return ( |
| 32 | <div> |
| 33 | <VideoPlayer ref={playerRef} src="/video.mp4" /> |
| 34 | <div className="controls"> |
| 35 | <button onClick={() => playerRef.current?.play()}>Play</button> |
| 36 | <button onClick={() => playerRef.current?.pause()}>Pause</button> |
| 37 | <button onClick={() => playerRef.current?.seek(30)}> |
| 38 | Skip to 30s |
| 39 | </button> |
| 40 | </div> |
| 41 | </div> |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | // Form component with imperative API |
| 46 | interface FormRef { |
| 47 | submit: () => Promise<boolean>; |
| 48 | reset: () => void; |
| 49 | isValid: () => boolean; |
| 50 | } |
| 51 | |
| 52 | const Form = React.forwardRef<FormRef, { children: React.ReactNode }>( |
| 53 | ({ children }, ref) => { |
| 54 | const [errors, setErrors] = useState<Record<string, string>>({}); |
| 55 | |
| 56 | useImperativeHandle(ref, () => ({ |
| 57 | submit: async () => { |
| 58 | const isValid = Object.keys(errors).length === 0; |
| 59 | if (isValid) { |
| 60 | // submit logic |
| 61 | return true; |
| 62 | } |
| 63 | return false; |
| 64 | }, |
| 65 | reset: () => { |
| 66 | setErrors({}); |
| 67 | }, |
| 68 | isValid: () => Object.keys(errors).length === 0, |
| 69 | })); |
| 70 | |
| 71 | return <form>{children}</form>; |
| 72 | } |
| 73 | ); |
Refs vs State
refs_vs_state.tsx
TSX
| 1 | // Key difference: refs don't trigger re-renders, state does |
| 2 | |
| 3 | // Use STATE when: |
| 4 | // - Value changes should update the UI |
| 5 | // - Value needs to be passed to children |
| 6 | // - Value is derived from props or other state |
| 7 | |
| 8 | function Counter() { |
| 9 | const [count, setCount] = useState(0); // UI updates on change |
| 10 | return <p>{count}</p>; |
| 11 | } |
| 12 | |
| 13 | // Use REFS when: |
| 14 | // - Accessing DOM nodes (focus, measurements, animations) |
| 15 | // - Storing values that persist across renders but don't affect UI |
| 16 | // - Storing interval/timeout IDs |
| 17 | // - Storing previous values |
| 18 | |
| 19 | function MeasureBox() { |
| 20 | const boxRef = useRef<HTMLDivElement>(null); |
| 21 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); |
| 22 | |
| 23 | useEffect(() => { |
| 24 | if (!boxRef.current) return; |
| 25 | const { width, height } = boxRef.current.getBoundingClientRect(); |
| 26 | setDimensions({ width, height }); |
| 27 | }, []); |
| 28 | |
| 29 | return ( |
| 30 | <div ref={boxRef} className="box"> |
| 31 | <p>{dimensions.width}x{dimensions.height}</p> |
| 32 | </div> |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | // Ref for mutable state that shouldn't cause re-renders |
| 37 | function useInterval(callback: () => void, delay: number | null) { |
| 38 | const savedCallback = useRef(callback); |
| 39 | |
| 40 | useEffect(() => { |
| 41 | savedCallback.current = callback; |
| 42 | }, [callback]); |
| 43 | |
| 44 | useEffect(() => { |
| 45 | if (delay === null) return; |
| 46 | const id = setInterval(() => savedCallback.current(), delay); |
| 47 | return () => clearInterval(id); |
| 48 | }, [delay]); |
| 49 | } |
| 50 | |
| 51 | // Anti-pattern: reading state in setTimeout/useInterval |
| 52 | // The closure captures the state at render time, not the current value |
| 53 | // Use useRef to always have the latest value |
| 54 | |
| 55 | // ✅ Correct pattern |
| 56 | function LatestValueExample() { |
| 57 | const [count, setCount] = useState(0); |
| 58 | const countRef = useRef(count); |
| 59 | |
| 60 | useEffect(() => { |
| 61 | countRef.current = count; |
| 62 | }, [count]); |
| 63 | |
| 64 | useEffect(() => { |
| 65 | const timer = setTimeout(() => { |
| 66 | console.log(`Latest count: ${countRef.current}`); |
| 67 | }, 5000); |
| 68 | return () => clearTimeout(timer); |
| 69 | }, []); |
| 70 | } |
Uncontrolled Components
uncontrolled.tsx
TSX
| 1 | // Uncontrolled components store their own state internally |
| 2 | // React accesses the value via ref when needed |
| 3 | |
| 4 | // Controlled (React manages state): |
| 5 | function ControlledInput() { |
| 6 | const [value, setValue] = useState(""); |
| 7 | return <input value={value} onChange={(e) => setValue(e.target.value)} />; |
| 8 | } |
| 9 | |
| 10 | // Uncontrolled (DOM manages state): |
| 11 | function UncontrolledInput() { |
| 12 | const inputRef = useRef<HTMLInputElement>(null); |
| 13 | |
| 14 | function handleSubmit() { |
| 15 | const value = inputRef.current?.value; |
| 16 | console.log("Submitted:", value); |
| 17 | } |
| 18 | |
| 19 | return ( |
| 20 | <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}> |
| 21 | <input ref={inputRef} defaultValue="initial" /> |
| 22 | <button type="submit">Submit</button> |
| 23 | </form> |
| 24 | ); |
| 25 | } |
| 26 | |
| 27 | // Uncontrolled file input — always uncontrolled (no value prop) |
| 28 | function FileUpload() { |
| 29 | const fileRef = useRef<HTMLInputElement>(null); |
| 30 | |
| 31 | function handleUpload() { |
| 32 | const files = fileRef.current?.files; |
| 33 | if (files && files.length > 0) { |
| 34 | console.log("Selected:", files[0].name); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | return ( |
| 39 | <div> |
| 40 | <input ref={fileRef} type="file" /> |
| 41 | <button onClick={handleUpload}>Upload</button> |
| 42 | </div> |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | // When to use uncontrolled: |
| 47 | // - Quick forms where you don't need real-time validation |
| 48 | // - File inputs (always uncontrolled) |
| 49 | // - Integrating with non-React code |
| 50 | // - Performance-critical inputs with many rapid changes |
| 51 | |
| 52 | // When to use controlled: |
| 53 | // - Real-time validation |
| 54 | // - Dynamic form fields |
| 55 | // - Conditionally disabling inputs |
| 56 | // - Input formatting (masking) |
| 57 | // - Submitting form data programmatically |
✓
best practice
Prefer controlled components for form inputs in React — they make validation, transformation, and conditional logic straightforward. Use uncontrolled components only for simple forms or when integrating with non-React code.
$Blueprint — Engineering Documentation·Section ID: REACT-REFS·Revision: 1.0