Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

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:


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author