As React apps grow, so does the complexity; and with it, the danger of components doing too much. Clean, modular code isn’t just a nice-to-have; it's how you build software that scales.
One of the most effective tools for writing maintainable React code is the custom hook. When used well, custom hooks separate concerns, improve readability, and make logic reusable across your app.
(Shameless plug: if you're looking for a growing collection of well-tested, composable React hooks built for real-world apps, check out Useful — a custom hook library I created.)
What Are Custom Hooks?
In React, a custom hook is a JavaScript function that:
-
Starts with
use
This naming convention is required by React so it can identify and enforce the Rules of Hooks. -
Calls other hooks
Custom hooks typically use built-in hooks likeuseState,useEffect,useContext, or even other custom hooks to encapsulate logic. -
Encapsulates reusable logic
They're used to extract and reuse stateful or side-effect logic across multiple components, promoting cleaner and more maintainable code. -
Returns values or functions
Usually returns state variables, handlers, or derived values that the consuming component can use.
⚠️ Note:
Custom hooks do not render UI and must follow the Rules of Hooks: they should only be called at the top level of a function component or another custom hook, and never conditionally or inside loops.
Example custom hook: useIsMobile
This hook determines if the current viewport is mobile (less than 768px wide) and returns a boolean value. It uses useState to manage the state and useEffect to update on window resize events.
import { useState, useEffect } from 'react'
/***
* Determines if the current viewport is mobile (i.e. less than 768px)
* @returns {boolean} - True if the viewport is mobile, false otherwise
*/
export function useIsMobile(): boolean {
const MOBILE_BREAKPOINT = 768
// we use regular old useState to manage the hook's mobile boolean state
const [isMobile, setIsMobile] = useState(false) // Safe default
useEffect(() => {
const handleResize = () =>
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
// Set initial value
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// true if the viewport is less than 768px, false otherwise
return isMobile
}
Usage for useIsMobile:
import { useIsMobile } from './useIsMobile'
/**
* Displays a message based on whether the viewport is mobile or desktop
*/
function App() {
// all mobile/desktop logic is "live" and abstracted away into the custom hook
const isMobile = useIsMobile()
return (
<div>
{isMobile ? (
<p>You are on a mobile device</p>
) : (
<p>You are on a desktop device</p>
)}
</div>
)
}
Why Custom Hooks?
Hooks like useState, useEffect, and useRef give you power, but custom hooks give you structure.
You can think of a custom hook as a dedicated block of logic or a shared source for a resource. Instead of stuffing everything into a component, pull logic into a dedicated function. You can test it, reuse it, and reason about it independently.
Before: The Bloated Component
When logic is spread across multiple components, it can become hard to reason about and maintain. This component is a prime example of blending business logic with presentation logic, obscuring the true purpose of the component.
import { useState, useEffect } from 'react'
/**
* Fetches a user from the API and returns the user data
* @param {string} id - The ID of the user to fetch
* @returns {Object} - The user data
*/
function UserCard({ id }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((data) => {
setUser(data)
setLoading(false)
})
.catch((err) => {
if (err.name === 'AbortError') return // Don't set error for cancelled requests
setError(err instanceof Error ? err : new Error('Failed to fetch user'))
setLoading(false)
})
}, [id, setUser, setLoading, setError])
if (loading) return <div>Loading…</div>
if (error) return <div>Error!</div>
return <div>{user.name}</div>
}
After: Split the Logic
Utilizing a custom hook, we can separate the logic from the presentation, making the component more focused and easier to maintain and extend:
import { useState, useEffect } from 'react'
interface User {
id: string
name: string
}
interface UseUserResult {
user: User | null
loading: boolean
error: Error | null
}
/**
* Fetches a user from the API and returns the user data
* @param {string} id - The ID of the user to fetch
* @returns {UseUserResult} - The user data
*/
function useUser(id: string): UseUserResult {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (!id) {
setError(new Error('ID is required'))
setLoading(false)
return
}
let isMounted = true
const controller = new AbortController()
const signal = controller.signal
const fetchData = async () => {
try {
const response = await fetch(`/api/users/${id}`, { signal })
if (!response.ok) {
throw new Error('Failed to fetch user')
}
const data = await response.json()
if (isMounted) {
setUser(data)
setLoading(false)
setError(null)
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error('Unknown error'))
setLoading(false)
}
}
}
fetchData()
return () => {
isMounted = false
controller.abort()
}
}, [id, setUser, setLoading, setError])
return { user, loading, error }
}
/**
* Displays a user card
* @param {string} id - The ID of the user to display
*/
function UserCard({ id }) {
// all logic is abstracted away into the custom hook, leaving the component simple and declarative
const { user, loading, error } = useUser(id)
if (loading) return <div>Loading…</div>
if (error) return <div>Error!</div>
return <div>{user.name}</div>
}
Custom Hooks Encourage Separation of Concerns
By moving logic into hooks:
- UI components stay small and declarative
- Logic becomes reusable and testable
- Teams can move faster with clearer boundaries
Some common examples:
- Fetching data:
usePost,useProducts - Form logic:
useForm,useValidation - DOM behavior:
useClickOutside,useScrollLock - User Interaction:
useThrottle,useDebounce - Animation state:
useToggle,useTimeout
Bonus: Hooks Compose Easily
Hooks can use other hooks. That means you can compose higher-level behavior without duplication. Here, we're using useUser and usePostsByUser to create a new hook that combines user and posts data into a single object:
interface UseUserWithPostsResult {
user: User | null
posts: Post[]
loading: boolean
}
/**
* Combines user and posts data into a single object
* @param {string} userId - The ID of the user to fetch
* @returns {UseUserWithPostsResult} - The user and posts data
*/
const useUserWithPosts = (userId): UseUserWithPostsResult => {
const { user, loading: loadingUser } = useUser(userId)
const { posts, loading: loadingPosts } = usePostsByUser(userId)
return {
user,
posts,
loading: loadingUser || loadingPosts,
}
}
Best Practices for Custom Hooks
- Create a custom hook when logic is reused or your component starts handling multiple concerns.
- Use clear, descriptive names that begin with
use, likeuseForm,useFetch, oruseIsMobile. - Follow the Rules of Hooks: only call hooks at the top level, and only inside function components or other hooks.
- Keep hooks focused: Each custom hook should ideally handle one piece of logic or functionality. This makes them easier to test and reuse.
Testing Custom Hooks
Custom hooks are also easy to test. Here, we'll use Vitest and the React Testing Library. The following defines and tests a custom hook that increments a counter:
import { useState } from 'react'
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
/**
* A simple counter hook to manage the state of a numeric counter
* @returns {Object} - The current count and functions to manipulate it
*/
const useCounter = () => {
const [count, setCount] = useState(0)
const increment = () => {
if (count >= 100) return
setCount((c) => c + 1)
}
const decrement = () => {
if (count <= 0) return
setCount((c) => c - 1)
}
const reset = () => setCount(0)
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
it('increments count', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
})
Final Thoughts
Custom hooks are more than a code organization trick; they're a scaling strategy.
They can:
- Enforce separation of concerns
- Improve readability
- Reduce duplication
If your React components are starting to feel bloated or tangled, that can be a signal that it's time to reach for a hook to give your logic a home of its own.