Custom Hook Composition: Build Bigger Features from Small Hooks
Combine focused hooks to keep complex logic clean, testable, and reusable.
A search box stops being trivial when the input state and the filtering live in the same component. The next screen that needs a filtered list copies both.
All of It Inline
Query state and the filter computation, both owned by App.
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;
At this size it’s fine. The problem is the shape. Anyone who needs filtered items elsewhere retypes the whole thing, and a change to the filtering rule now has two places to land.
useInput, useFilter, useSearch
I split the feature along its seams. useInput owns the query state. useFilter owns the memoized filtering. useSearch composes the two and is the only hook the component sees.
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;
useFilter wraps the computation in useMemo keyed on items and query, so it only reruns when one of those changes. The component drops back to pure rendering, each hook can be tested or reused on its own, and this is how I stop hooks from growing into one unreadable block.
Each hook owns one concern and returns the smallest API that covers it. Hooks this small look like barely anything, right up until the next feature needs one of them.