React

Custom React Hooks: Composable Patterns That Scale

Learn how to build custom React hooks that stay testable and composable as your codebase grows — with layered composition patterns that eliminate hidden coupling.

By Laxaar Engineering Team Sep 5, 2026 12 min read
Custom React Hooks: Composable Patterns That Scale

Custom React hooks are one of those features that feel simple until a codebase has hundreds of them and nobody can explain why changing one breaks three unrelated components. The pattern itself is clean: extract stateful logic into a function that starts with use, call it anywhere. The problem is that "anywhere" turns into "everywhere," and hooks that started as focused utilities grow into multi-concern blobs that carry implicit dependencies teams don't notice until something snaps.

A hook that does two things isn't twice as reusable; it's half as composable. The real problem is composition discipline, not hook complexity. When we audit large React codebases at Laxaar, the pain points almost always trace back to hooks that mix data fetching, local UI state, and side effects into a single function. Splitting them doesn't just clean up the code. It makes each layer independently testable and replaceable.

This guide walks through extracting a tangled component into layered custom React hooks, showing the composition rules the Laxaar team uses to keep things from coupling again.

What you'll learn

Why hooks couple silently and how to spot it

Silent coupling in hooks happens when a single hook manages multiple concerns that happen to need each other right now. The test is simple: can you use this hook in a different component without carrying baggage you don't need? If the answer is "sort of, but you'd get the loading spinner state too," the hook is already coupled.

Three signals that a hook has hidden coupling:

The hook returns more than five values. A function that returns ten properties is doing too much. Real composition looks like two or three hooks each returning three properties, and the calling component picks what it needs.

The hook has more than two useEffect calls. Each effect is a side effect with its own lifecycle. Multiple effects in one hook means multiple concerns in one function. They'll interfere with each other's cleanup and dependency arrays in ways that are genuinely hard to debug.

You can't test the hook without mocking its own internal dependencies. If useUserProfile makes a fetch call internally and you can't swap that out, you've lost the ability to test the data-shape logic without a live server. The fetch concern and the data-shape concern are fused.

The fix isn't to break every hook into atoms. It's to layer: primitives at the bottom, domain logic in the middle, UI-specific hooks on top.

The layered hook model: primitives, domain, UI

Layered hook architecture organizes custom hooks into three tiers, each depending only on the tier below it.

Primitive hooks wrap a single capability: a fetch call, a localStorage subscription, a WebSocket connection, a debounce. They know nothing about your domain.

Domain hooks compose primitives into business-meaningful shapes. useUserProfile calls useFetch and useLocalCache and returns a profile object with loading and error states. It knows about your data model but nothing about how any component will display it.

UI hooks manage presentation state that doesn't belong in a server or global store: whether a modal is open, which tab is active, a controlled input value. They might call a domain hook to get data, but they own display logic only.

This layering rule is the key constraint: hooks only depend on the tier below or at the same tier. A domain hook can use a primitive. A UI hook can use a domain hook. Nothing reaches upward. When teams skip this rule, the coupling silently re-appears.

Building a primitive hook that does one thing

Here's a useFetch primitive. It handles exactly one thing: performing an HTTP request and exposing its lifecycle states. No data transformation, no caching, no retry logic. Those are separate concerns.

import { useState, useEffect, useRef } from 'react'

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function useFetch<T>(url: string | null): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: 'idle' })
  const abortRef = useRef<AbortController | null>(null)

  useEffect(() => {
    if (!url) {
      setState({ status: 'idle' })
      return
    }

    abortRef.current?.abort()
    const controller = new AbortController()
    abortRef.current = controller

    setState({ status: 'loading' })

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json() as Promise<T>
      })
      .then((data) => setState({ status: 'success', data }))
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setState({ status: 'error', error: err })
        }
      })

    return () => controller.abort()
  }, [url])

  return state
}

export default useFetch

Notice what's absent: no useCallback wrapping the caller's functions, no knowledge of any component's state, no side effects beyond the fetch itself. This hook can be tested by rendering it with different URL inputs and asserting on its output states, with no component tree required.

Composing domain logic from primitive hooks

Domain hooks use primitives to produce business-meaningful results. Here's useUserProfile built on top of useFetch:

import useFetch from './useFetch'

type User = {
  id: string
  name: string
  email: string
  avatarUrl: string
}

type ProfileState =
  | { loading: true; user: null; error: null }
  | { loading: false; user: User; error: null }
  | { loading: false; user: null; error: Error }

function useUserProfile(userId: string | null): ProfileState {
  const url = userId ? `/api/users/${userId}` : null
  const fetchState = useFetch<User>(url)

  if (fetchState.status === 'loading' || fetchState.status === 'idle') {
    return { loading: true, user: null, error: null }
  }
  if (fetchState.status === 'error') {
    return { loading: false, user: null, error: fetchState.error }
  }
  return { loading: false, user: fetchState.data, error: null }
}

export default useUserProfile

The domain hook owns one decision: how to interpret the raw HTTP response as a User. It doesn't know how any component will render the profile, which tab it lives on, or whether there's a modal involved. That separation means we can test useUserProfile by mocking useFetch (or by pointing it at a test server) without touching any UI.

The composition chain is visible in the imports. If we ever swap useFetch for a React Query-based primitive, useUserProfile doesn't change at all. That's the payoff.

Keeping UI state separate from data state

The most common mistake we see in React codebases is a domain hook that also manages isEditing, activeTab, confirmDialogOpen, and similar display flags. These have nothing to do with the server data; they're component-level concerns that happen to live next to the fetch logic for convenience.

Here's a UI hook that owns display state and calls the domain hook for data:

import { useState, useCallback } from 'react'
import useUserProfile from './useUserProfile'

function useProfilePanel(userId: string | null) {
  const profile = useUserProfile(userId)
  const [isEditing, setIsEditing] = useState(false)
  const [activeSection, setActiveSection] = useState<'info' | 'settings'>('info')

  const startEditing = useCallback(() => setIsEditing(true), [])
  const cancelEditing = useCallback(() => setIsEditing(false), [])

  return {
    ...profile,
    isEditing,
    activeSection,
    startEditing,
    cancelEditing,
    setActiveSection,
  }
}

export default useProfilePanel

The component calling useProfilePanel gets a single, cohesive interface. If the product team adds a new section to the panel, you update useProfilePanel. If the user API changes, you update useUserProfile. If the fetch primitive's retry logic changes, you update useFetch. Each layer absorbs its own changes.

One honest trade-off here: spreading ...profile into the return value does create a shape dependency between the UI hook and the domain hook. If useUserProfile renames a property, useProfilePanel silently passes the wrong shape. For domain objects that change frequently, we prefer explicit property forwarding (user: profile.user, loading: profile.loading) and reserve the spread for stable, small shapes.

Comparison: tangled hook vs layered composition

ConcernTangled useProfilePanelLayered composition
Data fetchingInside the hook, hard to swapuseFetch primitive, swappable
Domain shape mappingMixed with UI stateIsolated in useUserProfile
Display state (isEditing)Mixed with fetch logicOwned by UI hook only
TestabilityRequires mocking fetch AND renderEach layer testable independently
Reuse of fetch logicCopy-pasted per featureOne useFetch used everywhere
Blast radius of API changeTouches component directlyAbsorbed by domain hook
Readability of componentLong, mixed concernsShort, reads like a list of intents

The tangled version ships faster on day one. The layered version wins from day thirty onward, when the API changes, the component gets reused, or a new engineer needs to understand what the hook actually does.

Testing custom hooks in isolation

Layered hooks are worth nothing if you don't test them. The good news is that @testing-library/react's renderHook makes this easy, and because each layer is independent, tests stay small.

import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import useUserProfile from './useUserProfile'

const server = setupServer(
  http.get('/api/users/u1', () =>
    HttpResponse.json({ id: 'u1', name: 'Alice', email: 'alice@example.com', avatarUrl: '' })
  )
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('returns user data after successful fetch', async () => {
  const { result } = renderHook(() => useUserProfile('u1'))

  expect(result.current.loading).toBe(true)

  await waitFor(() => expect(result.current.loading).toBe(false))

  expect(result.current.user?.name).toBe('Alice')
  expect(result.current.error).toBeNull()
})

test('returns error on failed fetch', async () => {
  server.use(http.get('/api/users/u1', () => HttpResponse.error()))

  const { result } = renderHook(() => useUserProfile('u1'))
  await waitFor(() => expect(result.current.loading).toBe(false))

  expect(result.current.error).toBeInstanceOf(Error)
  expect(result.current.user).toBeNull()
})

We use MSW to intercept network requests at the service-worker level, so the test exercises the real useFetch primitive without a running server. Each hook layer gets its own test file. The UI hook tests use renderHook with controlled initial state and assert on the display state transitions. No server mocking is needed at that layer, because the domain hook is mocked.

At Laxaar, this is the testing shape we recommend in our custom software development engagements: hooks tested independently, components tested with their full hook tree via integration tests, and server integration covered by a separate E2E layer.

Frequently Asked Questions

How many custom hooks is too many?

There's no upper limit, but there's a useful signal: if a hook has only one caller and could just be inlined into that caller with no loss of clarity, it probably shouldn't exist yet. Extract into a hook when the logic needs to be tested independently, reused across two or more places, or when it adds meaningful naming that clarifies intent. Don't extract for tidiness alone. The abstraction cost is real, and naming things poorly is worse than not naming them at all.

Should custom hooks live next to the component or in a shared folder?

Start next to the component. Move to a shared hooks folder only when a second caller needs it. This keeps coupling visible: if a hook is shared, it appears in a shared location; if it's private, it's co-located. The mistake is pre-emptively putting everything in a central hooks/ directory, which makes it hard to see which hooks are actually reused vs. which ones just ended up there.

Can you compose hooks that both call useContext?

Yes, but be deliberate. If two hooks each call the same context, they're implicitly coupled through that context. A change to the context shape breaks both. The cleaner pattern is a single domain hook that reads the context and passes the relevant slice down to other hooks as arguments. This makes the dependency explicit and keeps inner hooks free of context coupling: they accept plain values and stay reusable outside of the context provider tree.

Do React Server Components change how we write custom hooks?

Yes, meaningfully. Hooks only run in Client Components. Server Components can't call useState, useEffect, or any custom hook. The effect on architecture is that data-fetching hooks that used to live at the top of a component tree now belong in a Server Component as async functions, and the remaining client hooks shrink to purely client-side concerns: input state, UI interactions, subscriptions. This is actually a good thing for the layered model. The "primitive fetch hook" tier moves to the server, and client hooks become genuinely UI-scoped.

How do you prevent a custom hook from triggering unnecessary re-renders?

The main culprits are object and array literals created inside the hook's return statement, and functions not wrapped in useCallback. Every render creates a new object reference, which causes consumers to re-render even if the data hasn't changed. Return stable primitives where possible, memoize objects with useMemo when the shape is needed, and wrap callbacks in useCallback with accurate dependency arrays. In 2026, the React Compiler handles most of this automatically for codebases that have opted in, but the underlying principle of reference stability still matters when you're debugging why a component re-renders unexpectedly.


Building a React application and finding that hooks are growing in all directions? The Laxaar team helps product teams establish composable frontend architectures, from hook design patterns to full-stack React systems. Explore our web development services or reach out to talk through your codebase.

Working on something like this?

Get a fixed scope, timeline, and price within one business day — no obligation.

ReactCustom HooksHook Composition
Grow your business with us

Take your business to the next level.

Tell us what you're building. We'll come back inside one business day with a fixed scope, timeline, and team — or an honest “this isn't a fit”.

ENGINEERING PHILOSOPHY

Code is useless if it's not comprehensible to those who maintain it. We write code the next person can actually understand.