React

Custom Hooks: Naming and Structure

How to name, structure, and compose custom hooks so they stay readable and testable. One concern per hook, consistent return shapes, and when to split.

Custom hooks are one of the best things about the React model. They let you extract stateful logic from components and reuse it anywhere. But it's easy to write a hook that's technically fine and practically a mess. The name doesn't tell you what it does, it reaches into three different concerns at once, and testing it means setting up half your app.

Here's how I think about structuring them well.

Start with the Name

Custom hooks should use the use prefix. That naming convention helps the Rules of Hooks linting/tooling recognize them and makes their intent clear to readers. Beyond that, the name should describe what the hook returns or what it manages, not how it works internally.

// Too vague
useData()
useHelper()
useUtils()

// Describes what it manages
useCart()
useFormField()
useWindowSize()
useLocalStorage()

If you find yourself reaching for a name like useEverything or usePageLogic, that's a signal the hook is doing too much. Good names are narrow. They almost write the interface for you.

One Concern Per Hook

The most common mistake I see is bundling too many responsibilities into one hook. A hook that fetches data, formats it, tracks selection state, and handles errors is four hooks in a trench coat.

Here's an example of a hook that's grown too large:

// Too much happening in one place
function useProductPage(productId: string) {
    const [product, setProduct] = useState<Product | null>(null)
    const [selected, setSelected] = useState<string[]>([])
    const [loading, setLoading] = useState(false)
    const [error, setError] = useState<Error | null>(null)

    useEffect(() => {
        let active = true

        setLoading(true)
        setError(null)

        fetchProduct(productId)
            .then((nextProduct) => {
                if (active) {
                    setProduct(nextProduct)
                }
            })
            .catch((nextError) => {
                if (active) {
                    setError(nextError)
                }
            })
            .finally(() => {
                if (active) {
                    setLoading(false)
                }
            })

        return () => {
            active = false
        }
    }, [productId])

    const toggle = (id: string) =>
        setSelected((prev) =>
            prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
        )

    const formatted = product ? formatCurrency(product.price) : null

    return { product, selected, toggle, loading, error, formatted }
}

Split it along natural seams. Each piece can now be reused independently and tested without the other parts:

function useProduct(productId: string) {
    const [product, setProduct] = useState<Product | null>(null)
    const [loading, setLoading] = useState(false)
    const [error, setError] = useState<Error | null>(null)

    useEffect(() => {
        let cancelled = false

        setLoading(true)
        fetchProduct(productId)
            .then((nextProduct) => {
                if (!cancelled) {
                    setProduct(nextProduct)
                }
            })
            .catch((nextError) => {
                if (!cancelled) {
                    setError(nextError)
                }
            })
            .finally(() => {
                if (!cancelled) {
                    setLoading(false)
                }
            })

        return () => {
            cancelled = true
        }
    }, [productId])

    return { product, loading, error }
}

function useSelection<T>(items: T[]) {
    const [selected, setSelected] = useState<T[]>([])

    useEffect(() => {
        setSelected((prev) => prev.filter((item) => items.includes(item)))
    }, [items])

    const toggle = (item: T) =>
        setSelected((prev) => {
            if (!items.includes(item)) return prev
            return prev.includes(item)
                ? prev.filter((x) => x !== item)
                : [...prev, item]
        })

    return { selected, toggle }
}

The component composes them:

function ProductPage({ productId }: { productId: string }) {
    const { product, loading, error } = useProduct(productId)
    const { selected, toggle } = useSelection(product?.variants ?? [])

    if (loading) return <Spinner />
    if (error) return <ErrorMessage error={error} />

    return <ProductView product={product} selected={selected} onToggle={toggle} />
}

The component stays thin. The logic is portable.

Consistent Return Shapes

Pick a return convention and stick to it. I use objects for anything with more than one value. Tuples work well for simple two-value pairs where order is obvious (like useState itself).

// Tuple makes sense here: [value, setter] is a familiar pattern
const [count, setCount] = useState(0)

// Object makes sense here: more than two values, order isn't obvious
const { data, loading, error, refetch } = useProduct(id)

Mixing both inside a single codebase without reason makes it harder to predict what you're destructuring. If a hook always returns an object, you can add fields later without breaking call sites. Tuples are positional, so adding a third value is a breaking change for everyone using const [a, b] = useMyHook().

Testability Comes from Isolation

A hook that does one thing is easy to test. Wrap it in renderHook from @testing-library/react and assert on the return value.

import { renderHook, act } from '@testing-library/react'
import { useSelection } from './useSelection'

test('toggles items in and out of selection', () => {
    const { result } = renderHook(() => useSelection<string>([]))

    act(() => result.current.toggle('a'))
    expect(result.current.selected).toEqual(['a'])

    act(() => result.current.toggle('a'))
    expect(result.current.selected).toEqual([])
})

This works because useSelection has no network calls, no context dependencies, and no side effects beyond state. If your hook reaches into a context, hits an API, or reads from localStorage, you need to mock all of that in every test. That's not impossible, but it's a sign the hook is taking on too much.

When to Split

A few signals that a hook needs to be split:

  • The name requires "and" to describe what it does (useFetchAndFilter)
  • The return value has more than five or six keys
  • You find yourself passing the output of one part of the hook back into another part of the same hook
  • You want to test one part of the hook without caring about the other parts

Splitting doesn't always mean more files. Two small hooks in the same file is fine. What matters is that each hook has a clear reason to exist and a name that reflects it.

A Note on File Structure

I put each hook in its own file under a hooks/ directory, named to match the hook: useProduct.ts, useSelection.ts. For closely related hooks, a single hooks/cart.ts file can hold a few that belong together. What I avoid is dumping every hook into one useHooks.ts file, which makes both navigation and code review harder.

Custom hooks are the place where React logic lives when it isn't component-specific. Keeping them small, well-named, and focused on one thing makes the rest of the codebase easier to follow.

← Older
Props, Children, and Component Composition in React
Newer →
Controlled vs Uncontrolled Forms in React

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.