Back to all notes

Custom Hook Composition: Build Bigger Features from Small Hooks

Combine focused hooks to keep complex logic clean, testable, and reusable.

2 min read

Introduction

Custom hook composition is about building complex behavior by combining smaller, focused hooks. Each hook solves a specific problem, and together they form a complete feature.

Why this matters

Big hooks become hard to read and reuse. Splitting logic into focused hooks and composing them makes features modular and easier to test.

The problem

Filtering logic and input state are interleaved inside the same component, leading to duplication when reused elsewhere.

Inefficient approach

Inline state and filtering with no reuse:

import { useState } from "react";

const ITEMS = ["apple","banana","cherry","date","elderberry","fig","grape"];

function App() {
  const [query, setQuery] = useState("");
  const filteredItems = ITEMS.filter(item =>
    item.toLowerCase().includes(query.toLowerCase())
  );
  return (
    <div>
      <h1>Custom Hook Composition</h1>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>{filteredItems.map(i => <li key={i}>{i}</li>)}</ul>
    </div>
  );
}

export default App;

The solution

Create useInput for state, useFilter for memoized filtering, then compose them in useSearch.

import { useState, useMemo } from "react";

const ITEMS = ["apple","banana","cherry","date","elderberry","fig","grape"];

function useInput() {
  const [query, setQuery] = useState("");
  return { query, setQuery };
}

function useFilter(items, query) {
  return useMemo(
    () => items.filter((item) => item.toLowerCase().includes(query.toLowerCase())),
    [items, query]
  );
}

function useSearch(items) {
  const { query, setQuery } = useInput();
  const filteredItems = useFilter(items, query);
  return { query, setQuery, filteredItems };
}

function App() {
  const { query, setQuery, filteredItems } = useSearch(ITEMS);
  return (
    <div>
      <h1>Custom Hook Composition</h1>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>{filteredItems.map(i => <li key={i}>{i}</li>)}</ul>
    </div>
  );
}

export default App;

Step-by-step

  1. Extract input state into useInput returning { query, setQuery }.
  2. Extract filtering into useFilter(items, query) using useMemo.
  3. Compose both in useSearch(items) and return { query, setQuery, filteredItems }.
  4. Replace inline logic with the composed hook in components.

Tips

  • Keep each hook responsible for a single concern.
  • Return a stable, minimal API from each hook.
  • Don’t fear small hooks—they compose into powerful features.

Knowledge base

Problem snippet:

const filteredItems = ITEMS.filter(item =>
  item.toLowerCase().includes(query.toLowerCase())
);

Solution snippet:

const { query, setQuery, filteredItems } = useSearch(ITEMS);