The support ticket was one line long: “Searching muller finds nothing, but we have six invoices for Müller GmbH.” The engineer who picked it up searched for “muller” in the customer picker on a new invoice and found the customer at once. The same word in the invoice list found nothing.
Both screens had a search box, and both had been written by copying the other. A month earlier, someone had taught the picker to ignore accents. Nobody had a reason to open the invoice list that day, so its copy stayed as it was, and the two searches quietly became two different features.
The search needed one home that both screens call, and React’s name for that home is a custom hook: a function whose name starts with use, which calls other hooks and gives a component one piece of stateful behaviour. Hook composition is building that function from smaller hooks, each owning one concern, so the search is written once and every screen that needs it calls it.
Two searches that drifted apart
Here is the logic of the invoice list’s search, as the ticket found it:
// Avoid: the invoice list's own copy of the search
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const results = invoices.filter((invoice) =>
`${invoice.number} ${invoice.customer}`
.toLowerCase()
.includes(needle),
);And here is the customer picker’s, with the accent fix it gained a month ago:
// Avoid: the picker's copy, which has the accent fix
const [query, setQuery] = useState("");
const needle = fold(query.trim());
const results = customers.filter((customer) =>
fold(`${customer.name} ${customer.email}`).includes(needle),
);Each block is fine on its own. The trouble is that they are the same idea written twice: a query in state, folded for comparison, matched against some text from each row. When the rule for “matches” changed, it had two places to land and found one. The next screen with a search box would have copied whichever version its author happened to open first.
There is a second, quieter cost. Each component now mixes two jobs, keeping a search and drawing a screen, so a change to either means reading both.
Share the behaviour, not the state
A custom hook is a function a component calls while it renders. Whatever hooks the custom hook calls belong to the component that called it, as if their lines were written in its body. That is the whole mechanism, and it explains the rule that matters most: a custom hook shares stateful logic, not state itself. Two screens that call the same hook each get their own query.
Composition is the same idea applied again. useSearch doesn’t need to know how text is compared; it asks useFilter, which asks a plain function. Each layer answers one question, and each can change without the others noticing.
Building useSearch from smaller pieces
The rewrite starts at the bottom of the diagram, with the one rule both copies were meant to share, and works up to the screens.
The comparison, as a plain function
// How a search compares text: case and accents folded away, so
// "muller" finds "Müller GmbH" and "zoe" finds "Zoë". No hooks, so no
// "use".
export function normalise(text: string): string {
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
}normalise decomposes each accented letter into its base letter and a combining mark, removes the marks, and lowers the case. It calls no hook, so it doesn’t take the use prefix. That isn’t a style point: the prefix tells readers and the linter that the rules of hooks apply, and a plain function is free of them. It can run in a loop, on the server, or in a test with no React at all.
The matching, as a small hook
// The rows that match a query. It calls a hook (useMemo), so it is a
// hook.
import { useMemo } from "react";
import { normalise } from "./normalise";
export function useFilter<T>(
items: readonly T[],
query: string,
toText: (item: T) => string,
): readonly T[] {
return useMemo(() => {
const needle = normalise(query.trim());
if (!needle) return items;
return items.filter((item) =>
normalise(toText(item)).includes(needle),
);
}, [items, query, toText]);
}useFilter earns the prefix because it calls useMemo, so the rows are matched again only when the items, the query or the way of reading a row changes. An empty query returns the list it was given, unfiltered and unchanged in identity.
The search, composed
// The search a list screen needs: the query people type, and the rows
// that match it. Each component that calls it keeps a query of its
// own.
import { useState } from "react";
import { useFilter } from "./use-filter";
export function useSearch<T>(
items: readonly T[],
toText: (item: T) => string,
) {
const [query, setQuery] = useState("");
const results = useFilter(items, query, toText);
return { query, setQuery, results };
}useSearch is three lines of behaviour: a query, the rows that match it, and the setter to change it. Its return value is an object rather than a pair, because a caller reads it by name and a later field, such as a count of hidden rows, can join without moving the others.
The finished invoice search
"use client";
// The invoice list's search. The hook owns the query and the
// matching; the component owns the markup and what it announces.
import { useId } from "react";
import { useSearch } from "./use-search";
import type { Invoice } from "./records";
// Outside the component, so the filter sees the same function every
// render.
const invoiceText = (invoice: Invoice) =>
`${invoice.number} ${invoice.customer}`;
function matchText(count: number, query: string) {
if (count === 0) return `No invoices match “${query}”.`;
return count === 1
? "1 invoice matches."
: `${count} invoices match.`;
}
export function InvoiceSearch({
invoices,
}: {
invoices: readonly Invoice[];
}) {
const id = useId();
const { query, setQuery, results } = useSearch(
invoices,
invoiceText,
);
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>Invoices</h2>
<label htmlFor={`${id}-search`}>Search invoices</label>
<input
id={`${id}-search`}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{/* Empty until a search, so the first count is announced. */}
<p role="status">
{query.trim() ? matchText(results.length, query.trim()) : ""}
</p>
<ul>
{results.map((invoice) => (
<li key={invoice.id}>
{invoice.number} · {invoice.customer}
</li>
))}
</ul>
</section>
);
}Typing “muller” now lists INV-0046 for Müller GmbH and the status line reads “1 invoice matches.” The component is back to one job, drawing the screen, and the only search decision left in it is which text describes an invoice.
The picker, with the same hook
const { query, setQuery, results } = useSearch(
customers,
customerText,
);The picker passes its own rows and its own way of reading them, and keeps a query of its own. The next screen with a search box starts here rather than with a copy.
What a hook boundary costs and buys
A hook boundary is cheap to draw, so the questions worth asking are about where it goes and what crosses it.
A function argument is a dependency
useFilter lists toText among its dependencies, because it reads it. If a component passed an inline arrow, a new function would arrive on every render and the memo would never hold. The screens define invoiceText and customerText outside the component for that reason. A hook that takes a function should say whether it expects a stable one, and the simplest stable function is one declared at the top of the module.
Hooks re-run with their component
A custom hook runs on every render of the component that calls it, and like a component it must be pure. That is also why it always sees the latest props: there is no subscription to keep fresh. If the invoice list grows into the thousands, the cost of matching lands in the render that typed the letter, and the fix lives in one place. Wrapping the query in useDeferredValue inside useSearch keeps typing responsive, and neither screen changes a line.
What a hook can’t own
The status line that says “1 invoice matches.” lives in the component, not the hook. A hook returns values; the words, the label and the live region are markup, and only the screen knows what its rows are called. The region renders empty until someone searches, so the first count is announced rather than read once as part of the page. Keeping markup out of the hook is also what lets the same hook drive a different interface, as the Turtlekit version below shows.
Testing through the screens
Test a hook through a component that uses it, by role, as a person would meet it. Testing Library’s renderHook exists for hooks with no natural screen, but here the screens are the contract:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { CustomerPicker } from "./customer-picker";
import { InvoiceSearch } from "./invoice-search";
import type { Customer, Invoice } from "./records";
const INVOICES: Invoice[] = [
{ id: "1", number: "INV-0046", customer: "Müller GmbH" },
{ id: "2", number: "INV-0047", customer: "Zoë Larsen Studio" },
{ id: "3", number: "INV-0048", customer: "Northwind Traders" },
];
const CUSTOMERS: Customer[] = [
{
id: "c1",
name: "Müller GmbH",
email: "accounts@mueller.example",
},
{
id: "c2",
name: "Zoë Larsen Studio",
email: "zoe@larsen.example",
},
];
const rows = (name: string) =>
within(screen.getByRole("region", { name }))
.getAllByRole("listitem")
.map((li) => li.textContent);
test("matches whatever the accents, and says how many", async () => {
render(<InvoiceSearch invoices={INVOICES} />);
expect(screen.getByRole("status")).toHaveTextContent("");
await userEvent.type(
screen.getByRole("searchbox", { name: "Search invoices" }),
"muller",
);
expect(rows("Invoices")).toEqual(["INV-0046 · Müller GmbH"]);
expect(screen.getByRole("status")).toHaveTextContent(
"1 invoice matches.",
);
});
test("says so when nothing matches", async () => {
render(<InvoiceSearch invoices={INVOICES} />);
await userEvent.type(
screen.getByRole("searchbox", { name: "Search invoices" }),
"acme",
);
expect(screen.getByRole("status")).toHaveTextContent(
"No invoices match “acme”.",
);
});
test("each screen keeps its own query", async () => {
const onPick = vi.fn();
render(
<>
<InvoiceSearch invoices={INVOICES} />
<CustomerPicker customers={CUSTOMERS} onPick={onPick} />
</>,
);
await userEvent.type(
screen.getByRole("searchbox", { name: "Search invoices" }),
"zoe",
);
expect(rows("Invoices")).toEqual(["INV-0047 · Zoë Larsen Studio"]);
expect(
screen.getByRole("searchbox", { name: "Search customers" }),
).toHaveValue("");
expect(rows("Customer")).toHaveLength(2);
await userEvent.click(
screen.getByRole("button", { name: "Müller GmbH" }),
);
expect(onPick).toHaveBeenCalledWith("c1");
});All three pass. The last one is the property the ticket was about in reverse: the two screens share a rule, and each keeps its own query.
Hooks stop at the server boundary
useSearch calls useState, so it runs only in Client Components, which is why both screens open with "use client". normalise has no such limit. A server page that filters by a ?q= address before sending the list can call the same function, so the server and the browser agree on what “matches” means.
React 18, 19 and the Compiler
Custom hooks work the same way in React 18 and 19. The React Compiler memoises components and hooks, so in a compiled app useFilter would match the rows again only when its inputs changed even without useMemo. The React team’s advice for existing code is to leave manual memoisation in place, and in this hook it also documents the intent.
When to leave the code where it is
Extract a hook when the duplication is real or when naming the behaviour makes the component easier to read. A hook that wraps a single useState call adds a name and a file and removes nothing; react.dev makes the same point about a one-line form input hook. One screen with one search box keeps its three lines inline until a second screen needs them.
Avoid hooks named after a moment, such as useMount or useUpdateEffect. They hide an effect’s dependencies from the linter and describe when code runs rather than what it does. Name a hook for its purpose, as useSearch is. And if the shared part is markup rather than behaviour, a component with children or a render prop fits better.
How Turtlekit components take a hook’s values
The same useSearch drives the Turtlekit version of the invoice search without a change. TextField takes the query as value and reports each change through onValueChange, and DataTable takes the matching rows:
// The invoice search with Turtlekit: the same hook feeds a TextField
// and a DataTable, so the logic takes a new look without changing.
import { Search } from "lucide-react";
import { DataTable, Stack, TextField } from "turtlekit";
import type { DataTableColumn } from "turtlekit";
import { useSearch } from "./use-search";
import type { Invoice } from "./records";
const COLUMNS: DataTableColumn<Invoice>[] = [
{ key: "number", header: "Invoice" },
{ key: "customer", header: "Customer" },
];
const invoiceText = (invoice: Invoice) =>
`${invoice.number} ${invoice.customer}`;
export function InvoiceSearch({
invoices,
}: {
invoices: readonly Invoice[];
}) {
const { query, setQuery, results } = useSearch(
invoices,
invoiceText,
);
return (
<Stack gap="items">
<TextField
type="search"
label="Search invoices"
icon={<Search />}
value={query}
onValueChange={setQuery}
/>
<DataTable
caption="Invoices"
columns={COLUMNS}
rows={results}
getRowId={(invoice) => invoice.id}
getRowLabel={(invoice) =>
`${invoice.number}, ${invoice.customer}`
}
rowHeader="number"
/>
</Stack>
);
}The hook keeps the rule; the components bring the label, the search icon, the table’s caption and its row headers.
Built on this pattern in Turtlekit: Button, Calendar, Carousel, Checkbox, Combobox, Data table, Dialog, Field, Image slider, Logo cloud, Num, One-time code, Radio group, Resizable, Segmented control, Sidebar, Slider, Time, Toast and Toggle.
When a shared hook surprises you
Three surprises cover most questions about custom hooks.
Two components call the hook but don’t share the query
That is the design: each call owns its state. If two parts of the screen must show the same query, keep it in their closest common parent and pass it down, as Lifting state up in React describes.
The list is filtered again on every render
A dependency changes every time, usually a function or an array created inline in the component. Move it to the top of the module, or memoise it where it is created.
The linter says the hook is called conditionally
A custom hook follows the rules of hooks like any built-in one: call it at the top level, never after an early return or inside a branch. Return early after the call instead.
Small hooks outlast big ones
The ticket was about accents, but the fix was about where a decision lives. Once “matches” is one function, the invoice list and the picker can’t disagree, and the next screen with a search box inherits the rule instead of a snapshot of it. The pieces are small on purpose. A plain function for comparing, a hook for matching, a hook for the search: each is easy to read in full, and each could be replaced without the screens noticing. Large hooks tend to become the component they were extracted from, with the markup removed. Small ones stay true to their names.