How to type custom hooks in React with TypeScript
Typing custom hooks in React is essential for maintaining a scalable and bug-free codebase, especially as your application logic grows in complexity.
With over 25 years of experience in software development and as the creator of CoreUI, I have built and typed hundreds of reusable hooks for high-performance production environments.
The most efficient approach involves combining explicit return types, TypeScript generics for flexibility, and as const assertions for tuple-based returns.
Properly typed hooks ensure that your team receives accurate IntelliSense and prevents runtime errors across your entire React application.
Use explicit return type definitions or as const assertions to ensure TypeScript correctly infers the shape of your custom hook’s output.
Typing Hooks with Tuple Returns
When creating a hook that returns a pair of values (like useState), use the as const assertion to prevent TypeScript from widening the types into a union array.
import { useState, useCallback } from 'react'
/**
* useToggle hook for boolean state management
* Returns a tuple: [value, toggleFunction]
*/
export const useToggle = (initialValue: boolean = false) => {
const [value, setValue] = useState<boolean>(initialValue)
const toggle = useCallback(() => {
setValue((prev) => !prev)
}, [])
// 'as const' ensures the return type is [boolean, () => void]
// rather than (boolean | (() => void))[]
return [value, toggle] as const
}
// Usage in a component
const MyComponent = () => {
const [isOpen, toggleOpen] = useToggle(false)
return (
<button onClick={toggleOpen}>
{isOpen ? 'Close' : 'Open'}
</button>
)
}
This pattern is the industry standard for simple state-sharing hooks. By using as const, you allow the consumer to destructure the values with their specific types intact. This is a pattern we rely on heavily in our React Admin Template to keep component logic clean and predictable.
Using Interfaces for Object Returns
For more complex hooks that return multiple functions or values, returning an object with an explicit interface is often more readable and maintainable than a large tuple.
import { useState } from 'react'
interface UseCounterReturn {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
/**
* useCounter hook for numeric logic
* Returns an object with state and control functions
*/
export const useCounter = (initialValue: number = 0): UseCounterReturn => {
const [count, setCount] = useState<number>(initialValue)
const increment = () => setCount((c) => c + 1)
const decrement = () => setCount((c) => c - 1)
const reset = () => setCount(initialValue)
return {
count,
increment,
decrement,
reset
}
}
Defining an interface for the return value makes the hook’s contract explicit. This approach is beneficial when you expect the hook to be extended in the future, as adding new properties won’t break existing destructuring assignments in your components.
Implementing Generic Custom Hooks
Generics allow you to create hooks that work with any data type while maintaining full type safety. This is particularly useful for data fetching or form handling logic.
import { useState, useEffect } from 'react'
/**
* useFetch generic hook for API requests
* T represents the shape of the expected data
*/
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`)
}
const result = await response.json()
setData(result)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}
fetchData()
}, [url])
return { data, loading, error }
}
// Example usage with a specific User interface
interface User {
id: number
name: string
}
const UserProfile = ({ id }: { id: number }) => {
const { data, loading } = useFetch<User>(`https://api.example.com/users/${id}`)
if (loading) return <div>Loading...</div>
return <div>{data?.name}</div>
}
Generics make your hooks truly reusable. In this example, useFetch doesn’t need to know what a User is; the component providing the type parameter ensures that data is correctly typed throughout the component lifecycle.
Typing Hooks for DOM Events and Refs
When a hook interacts with DOM elements, you must use specific React types like RefObject and event handler types to ensure compatibility with React’s synthetic event system.
import { useRef, useEffect, RefObject } from 'react'
/**
* useClickOutside hook
* Detects clicks outside of a specific element
*/
export const useClickOutside = (
ref: RefObject<HTMLElement>,
callback: () => void
): void => {
useEffect(() => {
const handleClick = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback()
}
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('touchstart', handleClick)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('touchstart', handleClick)
}
}, [ref, callback])
}
// Usage with a CoreUI Modal
// https://coreui.io/react/docs/components/modal/
By typing the ref as RefObject<HTMLElement>, you ensure that the hook can be used with any valid HTML element. Using event.target as Node is a necessary type assertion when dealing with the DOM contains method, as event.target is typed as EventTarget by default.
Typing Hooks with Callback Logic
When passing functions into hooks, it is best practice to define the function signature clearly to avoid the any type and provide better developer feedback.
import { useState, useCallback } from 'react'
type ValidatorFn = (value: string) => boolean
/**
* useInput hook with validation
* Accepts a validation callback
*/
export const useInput = (initialValue: string, validator: ValidatorFn) => {
const [value, setValue] = useState<string>(initialValue)
const [isValid, setIsValid] = useState<boolean>(true)
const onChange = useCallback((newValue: string) => {
setValue(newValue)
setIsValid(validator(newValue))
}, [validator])
return {
value,
isValid,
onChange
}
}
Using a named type for the callback (ValidatorFn) improves readability. This pattern ensures that anyone using the hook provides a function that accepts a string and returns a boolean, preventing logic errors before the code even runs. Make sure to memoize the validator with useCallback at the call site so onChange does not get recreated on every render.
Integration with CoreUI Components
Typing hooks that manage UI state for components like Modals or Sidebars allows for seamless integration with libraries like CoreUI.
import { useState } from 'react'
interface ModalState {
visible: boolean
toggle: () => void
show: () => void
hide: () => void
}
/**
* useModalState hook
* Designed to work with CoreUI CModal component
*/
export const useModalState = (initial: boolean = false): ModalState => {
const [visible, setVisible] = useState<boolean>(initial)
return {
visible,
toggle: () => setVisible(!visible),
show: () => setVisible(true),
hide: () => setVisible(false)
}
}
// Integration:
// <CModal visible={visible} onClose={hide}>...</CModal>
// Documentation: https://coreui.io/react/docs/components/modal/
This hook simplifies the management of CoreUI Modal visibility. By providing a clear ModalState interface, you make the UI logic predictable and easy to test.
Best Practice Note:
Always prefer explicit return types for public-facing hooks in a shared library. While TypeScript’s inference is powerful, explicit types serve as documentation for other developers and ensure that internal changes to the hook don’t accidentally change the public API. We use this strict typing in our CoreUI components to maintain the high reliability our users expect. For help with array manipulation inside your hooks, you might find our guide on how to remove duplicates from an array in JavaScript useful.



