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

How to use TypeScript with React hooks

Integrating TypeScript with React hooks is essential for building scalable, bug-free applications in the modern web ecosystem.
With over 25 years of experience in software development and as the creator of CoreUI, I have built dozens of production-grade libraries where type safety was the foundation of reliability.
The most efficient way to use TypeScript with hooks is by leveraging Generics, which allow you to define the shape of your data while maintaining the flexibility React provides.
By explicitly typing your hooks, you ensure that your components are self-documenting and that common errors are caught at compile-time rather than in production.

Use Generics like useState<Type> and useRef<HTMLElement> to provide explicit type definitions for your React hooks.

1. Typing useState with Interfaces

The useState hook is often capable of inferring simple types like strings or numbers. However, for complex objects or states that start as null, you should use a generic. In professional environments like our React Dashboard Template, we always define interfaces for state objects to ensure consistency.

import React, { useState } from 'react'

interface UserProfile {
  id: string
  username: string
  email: string
  isActive: boolean
}

const UserComponent = () => {
  // We use a generic to tell TypeScript this state can be a UserProfile or null
  const [user, setUser] = useState<UserProfile | null>(null)

  const loginUser = () => {
    setUser({
      id: '1',
      username: 'coreui_dev',
      email: '[email protected]',
      isActive: true
    })
  }

  return (
    <div>
      {user ? <p>Welcome, {user.username}</p> : <button onClick={loginUser}>Login</button>}
    </div>
  )
}

export default UserComponent

In this example, useState<UserProfile | null>(null) ensures that any attempt to set the state to an object that does not match the UserProfile interface will result in a TypeScript error. This prevents runtime crashes when accessing properties like user.username.

2. Handling useRef and DOM Elements

When working with DOM elements, such as focusing a CoreUI Form Control, you must provide the specific HTML element type to useRef. This provides full IntelliSense for element-specific properties and methods.

import React, { useRef } from 'react'
import { CFormInput, CButton } from '@coreui/react'

const SearchInput = () => {
  // Explicitly type the ref as an HTMLInputElement
  const inputRef = useRef<HTMLInputElement>(null)

  const handleFocus = () => {
    // TypeScript knows focus() exists on HTMLInputElement
    // The optional chaining ?. handles the initial null state
    inputRef.current?.focus()
  }

  return (
    <>
      <CFormInput 
        ref={inputRef} 
        type='text' 
        placeholder='Search...' 
      />
      <CButton color='primary' onClick={handleFocus}>
        Focus Input
      </CButton>
    </>
  )
}

By typing the ref as useRef<HTMLInputElement>(null), TypeScript ensures that you don’t accidentally try to access properties that don’t exist on an input element. It also helps when passing refs to third-party components or CoreUI primitives.

3. Type-Safe useReducer with Discriminated Unions

For complex state logic, useReducer is the preferred choice. The best practice is to use Discriminated Unions for actions. This pattern is a staple in the CoreUI Admin Dashboard Template for managing global UI states like sidebar toggles or theme switching.

import React, { useReducer } from 'react'

type State = {
  count: number
  status: 'idle' | 'loading' | 'error'
}

type Action =
  | { type: 'increment' }
  | { type: 'decrement'; payload: number }
  | { type: 'set_status'; status: State['status'] }

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 }
    case 'decrement':
      // TypeScript narrows the type here, so action.payload is guaranteed to exist
      return { ...state, count: state.count - action.payload }
    case 'set_status':
      return { ...state, status: action.status }
    default:
      return state
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, { count: 0, status: 'idle' })

  return (
    <button onClick={() => dispatch({ type: 'decrement', payload: 5 })}>
      Decrease by 5
    </button>
  )
}

Using discriminated unions (type Action) allows the switch statement to narrow down the action type. If you try to dispatch an action with a missing payload or an invalid type, TypeScript will flag it immediately. If you need to process input strings before dispatching, you can see how to convert a string to a number in JavaScript to keep your payloads clean.

4. Typing useContext for Global State

Context is powerful but can be tricky to type because the default value often doesn’t match the final value. Defining a dedicated Provider type ensures that consumers of the context always receive the expected shape.

import React, { createContext, useContext, ReactNode, useState } from 'react'

interface ThemeContextType {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  
  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export const useTheme = () => {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within a ThemeProvider')
  return context
}

This pattern of throwing an error if the context is undefined is a robust way to handle the “initial value” problem in TypeScript. It guarantees that when you call useTheme() in a component, the returned object is fully typed and defined.

5. useMemo and useCallback Type Inference

Most of the time, useMemo and useCallback infer types correctly from their return values or function signatures. However, when working with complex event handlers or callbacks passed to memoized CoreUI Buttons, explicit typing helps avoid any types.

import React, { useCallback, useMemo } from 'react'

const MemoizedComponent = ({ factor }: { factor: number }) => {
  // useCallback inferring (event: React.MouseEvent) -> void
  const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
    console.log('Clicked at', event.clientX)
  }, [])

  // useMemo inferring the return type as number
  const calculatedValue = useMemo<number>(() => {
    return 100 * factor
  }, [factor])

  return (
    <button onClick={handleClick}>
      Value: {calculatedValue}
    </button>
  )
}

While explicit generics like useMemo<number> are optional when inference works, they provide an extra layer of safety when the logic inside the hook becomes complex or involves external utility functions.

Best Practice Note:

Always prefer interfaces over inline types for hook state. This makes your code more reusable and easier to debug. At CoreUI, we use this exact approach to maintain our open-source libraries, ensuring that every component prop and internal state is strictly typed for the best developer experience. For a deeper dive into component typing, you might also want to check how to type props in React with TypeScript.


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