Ref Forwarding
Forwarding refs to the right element, typing them correctly, and keeping imperative handles minimal.
Wrapper component swallows ref
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
onClick?: () => void;
}interface ButtonProps
extends React.ComponentProps<'button'> {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}Extending ComponentProps<'button'> gives you ref, onClick, children, disabled, ARIA props, and every other native button attribute for free. A manually defined interface with no ref support means consumers can't focus the button programmatically or measure its position.
In React 19, ref is a regular prop — extending native props is the simplest way to support it.
Ref typed as any
interface TextFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref?: any;
}interface TextFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
ref?: React.Ref<HTMLInputElement>;
}React.Ref<HTMLInputElement> accepts callback refs, ref objects, and null — all valid ref forms. It also tells TypeScript the exact element type, so ref.current.focus() and .select() autocomplete correctly.
any bypasses all of that — consumers can pass a string, a number, or anything else without a type error.
Imperative handle vs raw DOM ref
interface VideoPlayerProps {
/** Video source URL. */
src: string;
/** Poster image shown before playback. */
poster?: string;
/** Whether to show playback controls. */
hasControls?: boolean;
/**
* Ref to the root container element.
* Use for play/pause/seek operations.
*/
ref?: React.Ref<HTMLDivElement>;
}interface VideoPlayerHandle {
/** Start playback. */
play: () => void;
/** Pause playback. */
pause: () => void;
/** Seek to a specific time in seconds. */
seek: (time: number) => void;
}
interface VideoPlayerProps {
/** Video source URL. */
src: string;
/** Poster image shown before playback. */
poster?: string;
/** Whether to show playback controls. */
hasControls?: boolean;
/** Imperative handle for playback control. */
ref?: React.Ref<VideoPlayerHandle>;
}An imperative handle exposes only the operations consumers need: play(), pause(), seek(). Exposing the raw <div> leaks every DOM method and property — consumers could accidentally call .remove() or read .innerHTML.
useImperativeHandle creates this focused API. The handle type also serves as documentation for what the component supports.
Ref targeting the wrong element
interface SearchFieldProps {
/** Current search query. */
value: string;
/** Called when the query changes. */
onValueChange: (value: string) => void;
/** Placeholder text. */
placeholder?: string;
/**
* Ref to the outer wrapper element.
* Used for layout measurements.
*/
ref?: React.Ref<HTMLDivElement>;
}interface SearchFieldProps {
/** Current search query. */
value: string;
/** Called when the query changes. */
onValueChange: (value: string) => void;
/** Placeholder text. */
placeholder?: string;
/**
* Ref to the inner input element.
* Supports .focus() and .select().
*/
ref?: React.Ref<HTMLInputElement>;
}Consumers ref a SearchField to call .focus() or .select() — those are HTMLInputElement methods. A ref to the outer <div> wrapper can't do either. The ref should point to the element consumers actually interact with programmatically.
If consumers also need the wrapper for layout, expose that as a separate containerRef prop.
Over-exposed imperative handle
interface EditorHandle {
/** Get the current editor content. */
getValue: () => string;
/** Replace the editor content. */
setValue: (value: string) => void;
/** Focus the editor. */
focus: () => void;
/** Blur the editor. */
blur: () => void;
/** Get the current selection range. */
getSelection: () => SelectionRange;
/** Set the selection range. */
setSelection: (range: SelectionRange) => void;
/** Undo the last change. */
undo: () => void;
/** Redo the last undone change. */
redo: () => void;
}
interface EditorProps {
/** Controlled content value. */
value: string;
/** Called on content change. */
onChange: (value: string) => void;
/** Imperative editor API. */
ref?: React.Ref<EditorHandle>;
}interface EditorHandle {
/** Move focus to the editor. */
focus: () => void;
/** Remove focus from the editor. */
blur: () => void;
/** Scroll a line number into view. */
scrollToLine: (line: number) => void;
}
interface EditorProps {
/** Controlled content value. */
value: string;
/** Called on content change. */
onChange: (value: string) => void;
/** Selection range. Controlled by parent. */
selection?: SelectionRange;
/** Called when the selection changes. */
onSelectionChange?: (range: SelectionRange) => void;
/** Imperative editor API. */
ref?: React.Ref<EditorHandle>;
}The imperative handle should only expose what can't be done through props. getValue/setValue duplicate the controlled value/onChange pair. Selection is better as controlled props (selection/onSelectionChange) so the parent can react to changes. undo/redo are internal state management.
What remains — focus(), blur(), scrollToLine() — are genuine imperative operations that have no declarative equivalent.