Published

Custom Hooks: A Key to Clean and Scalable React Code

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 like useState, 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, like useForm, useFetch, or useIsMobile.
  • 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.

Back to Blog