TypeScript useRef Hook: Type Refs and DOM Elements
useRef creates a mutable container that persists across renders without triggering re-renders. TypeScript requires you to specify what the ref holds—a DOM element, a timer ID, or a custom object—so that accessing ref.current is type-safe and IntelliSense knows what methods are available.
In production codebases, untyped refs were a frequent source of crashes. Developers would forget that ref.current could be null, or try to call a method that doesn't exist on the element. TypeScript catches both before your users see them.
Typing DOM Element Refs
The most common use of useRef is to access DOM elements directly. When you pass a ref to a native HTML element, TypeScript knows the element type, but you must specify it in the generic:
function TextInput() {
// Explicitly typed: ref holds an HTMLInputElement or null
const inputRef = React.useRef<HTMLInputElement>(null);
const focusInput = () => {
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus input</button>
</>
);
}
Without the explicit <HTMLInputElement> generic, TypeScript infers the type from the initial value null, and you lose access to input-specific methods like focus(). By specifying the element type, IntelliSense provides autocomplete for all valid methods and properties.
Different HTML elements have different types. Here are the most common:
| Element | TypeScript Type |
|---|---|
<input /> | HTMLInputElement |
<button /> | HTMLButtonElement |
<textarea /> | HTMLTextAreaElement |
<div /> | HTMLDivElement |
<video /> | HTMLVideoElement |
<canvas /> | HTMLCanvasElement |
Each type includes the methods and properties specific to that element. For instance, HTMLInputElement has value, checked, and focus(), while HTMLVideoElement has play(), pause(), and currentTime.
Here's a video example:
function VideoPlayer() {
const videoRef = React.useRef<HTMLVideoElement>(null);
const playVideo = () => {
videoRef.current?.play();
};
const pauseVideo = () => {
videoRef.current?.pause();
};
return (
<>
<video ref={videoRef} src="video.mp4" width={400} />
<button onClick={playVideo}>Play</button>
<button onClick={pauseVideo}>Pause</button>
</>
);
}
The optional chaining operator ?. prevents crashes if the ref hasn't been attached yet (e.g., during SSR or if the component unmounts). TypeScript understands the nullability and allows you to use ?. safely.
Mutable Refs for Non-DOM Values
useRef also holds arbitrary mutable values, such as timer IDs, fetch abort controllers, or object references. These refs don't trigger re-renders, making them ideal for side-effect counters or cleanup logic:
function Stopwatch() {
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const [seconds, setSeconds] = React.useState(0);
const start = () => {
// Each setInterval call returns a unique timer ID
intervalRef.current = setInterval(() => {
setSeconds((prev) => prev + 1);
}, 1000);
};
const stop = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
return (
<>
<p>Time: {seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}
Here, intervalRef holds the result of setInterval, which is a NodeJS.Timeout. By typing the ref, you tell TypeScript that intervalRef.current is either a timeout or null. When you call clearInterval, TypeScript validates that the argument is a Timeout.
Generic Refs for Custom Components
When you create a custom component and want to expose a ref, use the forwardRef wrapper (covered in a later article). Inside the component, you can define custom ref types:
interface CustomHandles {
reset: () => void;
getValue: () => string;
}
const CustomInput = React.forwardRef<CustomHandles, { defaultValue: string }>(
({ defaultValue }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
// Expose methods via the imperative handle
React.useImperativeHandle(ref, () => ({
reset: () => {
if (inputRef.current) {
inputRef.current.value = '';
}
},
getValue: () => inputRef.current?.value ?? '',
}));
return <input ref={inputRef} defaultValue={defaultValue} />;
}
);
function App() {
const customRef = React.useRef<CustomHandles>(null);
const handleReset = () => {
customRef.current?.reset();
};
return (
<>
<CustomInput ref={customRef} defaultValue="Hello" />
<button onClick={handleReset}>Reset</button>
</>
);
}
This pattern allows you to define an interface (CustomHandles) that describes the ref's public API. Callers know exactly which methods are available, and TypeScript validates their calls.
Abort Controllers and Cleanup
For fetch requests and other async operations, use AbortController with refs to enable cancellation:
function DataFetcher() {
const abortRef = React.useRef<AbortController | null>(null);
const [data, setData] = React.useState<string | null>(null);
React.useEffect(() => {
abortRef.current = new AbortController();
fetch('/api/data', { signal: abortRef.current.signal })
.then((r) => r.json())
.then((result) => setData(result.text))
.catch((err) => {
if (!(err instanceof Error && err.name === 'AbortError')) {
console.error('Fetch failed:', err);
}
});
return () => {
abortRef.current?.abort();
};
}, []);
return <div>{data}</div>;
}
Typing the ref as AbortController | null ensures you handle both the uninitialized state (null) and the active controller. The optional chaining ?.abort() is safe even if the ref is null.
Combining useRef with useCallback
When you need a function to capture the latest state without depending on it, use a ref to store the function:
function DebouncedInput() {
const [value, setValue] = React.useState('');
const valueRef = React.useRef(value);
const timerRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
valueRef.current = value;
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
console.log('User stopped typing:', valueRef.current);
}, 1000);
};
return <input onChange={handleChange} />;
}
This pattern avoids recreating the event handler on every render, while still accessing the latest value through the ref.
Key Takeaways
- Type DOM element refs with the specific element type (e.g.,
HTMLInputElement) to enable method autocomplete and prevent null-access errors. - Use
NodeJS.TimeoutforsetIntervalandsetTimeoutRefforsetTimeoutresults. - Use
AbortControllerrefs to cancel async operations cleanly on component unmount. - Always check that
ref.currentis not null before calling methods (use?.optional chaining). - Generic ref types expose imperative APIs for custom components; pair with
useImperativeHandle.
Frequently Asked Questions
Why is ref.current nullable?
A ref is not immediately attached when the component mounts. During server-side rendering or before React paints the DOM, the ref is null. Always check before using it.
Can I update state directly from a ref?
Refs don't trigger re-renders. If you need the UI to update, use state instead. Refs are for side effects, timer management, and direct DOM access.
How do I type the result of setInterval?
Use NodeJS.Timeout. Similarly, setTimeout also returns NodeJS.Timeout. Avoid the deprecated NodeJS.Timer type.
What is the difference between useRef and a module-level variable?
useRef persists across renders and is tied to a component instance (one ref per component instance). A module-level variable persists globally and is shared across all instances, leading to bugs in concurrent rendering.