How to type refs in React with TypeScript
Managing DOM elements or keeping mutable values between renders in React requires the useRef hook, but doing it correctly in TypeScript can be tricky for many developers. With over 25 years of experience in software development and as the creator of CoreUI, I’ve implemented thousands of components where precise type definitions for refs were critical for stability. The most efficient and modern way to type refs is by using TypeScript generics to specify the exact DOM element or value type the ref will hold. This approach eliminates the need for type assertions and ensures that your IDE provides accurate autocompletion and error checking.
Pass the specific HTML element type as a generic parameter to the useRef hook, such as useRef<HTMLInputElement>(null).
import React, { useRef } from 'react'
const TextInput = () => {
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => {
inputRef.current?.focus()
}
return (
<div>
<input ref={inputRef} type='text' />
<button onClick={focusInput}>Focus Input</button>
</div>
)
}
When you call useRef<HTMLInputElement>(null), TypeScript creates a RefObject<HTMLInputElement>. This means the current property is read-only for React to manage the DOM node, and it is typed as HTMLInputElement | null. The initial value null is mandatory if you want React to assign the DOM element to the ref automatically.
1. Typing DOM Refs for Standard Elements
The most frequent use case for refs is interacting with a DOM node directly, such as focusing an input, measuring a div, or triggering an animation.
import React, { useRef, useEffect } from 'react'
const AutoFocusInput: React.FC = () => {
// Use HTMLInputElement for standard <input /> elements
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// TypeScript ensures .current exists before calling .focus()
// The optional chaining (?.) handles the initial null state
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
const handleClear = () => {
if (inputRef.current) {
inputRef.current.value = ''
}
}
return (
<div className='mb-3'>
<label htmlFor='username' className='form-label'>Username</label>
<input
ref={inputRef}
id='username'
type='text'
className='form-control'
placeholder='Enter your name'
/>
<button onClick={handleClear} className='btn btn-secondary mt-2'>
Clear Input
</button>
</div>
)
}
export default AutoFocusInput
In this example, we provide HTMLInputElement to the generic. If you were targeting a <div>, you would use HTMLDivElement. This ensures that properties like .focus() or .value are correctly typed and validated by the compiler.
2. Using Refs with CoreUI Components
When working with library components like those in CoreUI, you often need to access the underlying DOM element for manual manipulation or integration with third-party libraries.
import React, { useRef } from 'react'
import { CFormInput, CButton } from '@coreui/react'
const CoreUIInputRef: React.FC = () => {
// CoreUI components usually expose their root element as a ref
const cInputRef = useRef<HTMLInputElement>(null)
const logValue = () => {
if (cInputRef.current) {
const val = cInputRef.current.value
console.log('Current value:', val)
// You might use this value in other logic
}
}
return (
<>
<CFormInput
type='text'
ref={cInputRef}
placeholder='Type something...'
feedbackValid='Looks good!'
/>
<CButton color='primary' onClick={logValue} className='mt-2'>
Log Value
</CButton>
</>
)
}
Most CoreUI React components use forwardRef, allowing you to pass a standard DOM ref directly. This maintains the clean API developers expect while providing full TypeScript support.
3. Mutable Value Refs (Non-DOM)
Refs aren’t just for the DOM; they are also useful for storing mutable values that don’t trigger a re-render when they change, such as timer IDs or previous state values.
import React, { useRef, useState, useEffect, useCallback } from 'react'
const TimerComponent: React.FC = () => {
const [seconds, setSeconds] = useState(0)
// For mutable refs, pass undefined as the initial value
// This creates a MutableRefObject where .current is writable
const timerRef = useRef<number | undefined>(undefined)
const stopTimer = useCallback(() => {
if (timerRef.current !== undefined) {
clearInterval(timerRef.current)
timerRef.current = undefined
}
}, [])
const startTimer = () => {
if (timerRef.current !== undefined) return
timerRef.current = window.setInterval(() => {
setSeconds((s) => s + 1)
}, 1000)
}
// Clean up on unmount to prevent memory leaks
useEffect(() => {
return () => stopTimer()
}, [stopTimer])
return (
<div className='p-4 border rounded'>
<h4>Timer: {seconds}s</h4>
<div className='d-flex gap-2'>
<button onClick={startTimer} className='btn btn-success'>Start</button>
<button onClick={stopTimer} className='btn btn-danger'>Stop</button>
</div>
</div>
)
}
The key difference from DOM refs is the initial value. Passing undefined instead of null makes TypeScript create a MutableRefObject where .current is writable. If you pass null with useRef<number | null>(null), TypeScript treats .current as read-only, which will cause a compilation error when you try to assign to it.
4. Forwarding Refs in Custom Components
If you are building a reusable component, you should use React.forwardRef so parents can access your internal DOM nodes. This requires specific typing for both the ref and the props.
import React, { forwardRef } from 'react'
interface CustomButtonProps {
label: string
onClick: () => void
}
// forwardRef takes two generics: <RefType, PropType>
const CustomButton = forwardRef<HTMLButtonElement, CustomButtonProps>((props, ref) => {
return (
<button
ref={ref}
onClick={props.onClick}
className='btn btn-outline-info'
>
{props.label}
</button>
)
})
// DisplayName is helpful for debugging in React DevTools
CustomButton.displayName = 'CustomButton'
const ParentComponent = () => {
const btnRef = useRef<HTMLButtonElement>(null)
const handleClick = () => {
console.log('Button DOM element:', btnRef.current)
}
return (
<CustomButton
ref={btnRef}
label='Click Me'
onClick={handleClick}
/>
)
}
Using forwardRef<HTMLButtonElement, CustomButtonProps> ensures that the ref passed from the parent is correctly identified as a button element ref, providing full type safety in the parent component.
5. Typing useImperativeHandle
Sometimes you want to hide the internal DOM structure of a component but expose specific methods to the parent. This is done with useImperativeHandle.
import React, { useRef, useImperativeHandle, forwardRef, useState } from 'react'
// Define the shape of the handle
export interface FancyInputHandle {
focus: () => void
shake: () => void
}
const FancyInput = forwardRef<FancyInputHandle, {}>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null)
const [isShaking, setIsShaking] = useState(false)
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus()
},
shake: () => {
setIsShaking(true)
setTimeout(() => setIsShaking(false), 500)
}
}))
return (
<input
ref={inputRef}
className={`form-control ${isShaking ? 'is-invalid' : ''}`}
style={{ transition: 'transform 0.1s' }}
/>
)
})
const LoginScreen = () => {
const fancyInputRef = useRef<FancyInputHandle>(null)
const triggerError = () => {
fancyInputRef.current?.shake()
fancyInputRef.current?.focus()
}
return (
<div>
<FancyInput ref={fancyInputRef} />
<button onClick={triggerError} className='btn btn-warning mt-2'>
Trigger Error Animation
</button>
</div>
)
}
In this pattern, the generic passed to useRef in the parent is the interface FancyInputHandle, not a DOM element. This provides a clean, typed API for child-parent communication.
6. Typing Callback Refs
Callback refs offer more control than useRef, especially when dealing with dynamic lists or cleaning up side effects when a node is removed.
import React, { useCallback } from 'react'
const MeasuredNode: React.FC = () => {
// The callback receives the element or null
const measuredRef = useCallback((node: HTMLDivElement | null) => {
if (node !== null) {
const height = node.getBoundingClientRect().height
console.log('Node height:', height)
}
}, [])
return (
<div
ref={measuredRef}
className='p-3 bg-light border'
style={{ height: '150px' }}
>
I am being measured on mount.
</div>
)
}
Typing the argument as HTMLDivElement | null is critical here because React calls the function with null when the component unmounts. This is the same logic we use in complex CoreUI layouts to handle responsive measurements.
Best Practice Note:
Always initialize DOM refs with null. Failing to do so can lead to TypeScript thinking the ref is mutable by you rather than React. For performance-critical apps, using refs instead of state for values that don’t need to be rendered (like scroll positions) is a common optimization we use in CoreUI to keep components snappy.
Related answers:



