01 — Start with a static list
Look at the panel on the right. Five fruit names, a <ul>, nothing else. No state, no events, no interactivity. Check the state inspector at the bottom — items is the only piece of data in the entire component.
This is deliberate emptiness. The instinct is to wire everything up at once — state, events, filtering — and see if it works. But if something looks wrong after wiring three layers simultaneously, which layer broke? You'd have no idea.
Starting static gives you a foundation you can trust. Does the data show up? Right order? Correct markup? Each question has exactly one place to look. In production codebases, the components easiest to debug are the ones where you can point at the data layer and the render layer independently.
We're about to add interactivity. Everything that follows builds on this list working correctly.
02 — Wire up a controlled input
A text input just appeared in the demo. Type something — anything — and watch the state inspector. See query update on every keystroke?
That live synchronization is not the browser's doing. In vanilla HTML, an <input> manages its own state — you type, the DOM updates, and JavaScript reads whenever it feels like it. React flips that: the component tells the input what to display, and the input reports every keystroke back.
This is a controlled component. React gets the last word on what the input displays. The value prop locks the input to the state, and onChange is the only way to change it. Remove either half and the input breaks — try mentally deleting onChange and the input becomes read-only, because React keeps resetting it to the unchanged query.
The list still ignores your typing. Type “Cherry” and all five items remain. That disconnect is intentional — we've built two independent pieces (a list and an input) and verified each works alone. The payoff of controlling query in state comes next: because it's a React value, we can use it to compute anything. Like a filtered list.
03 — Filter the list on every keystroke
Type "a" — Apple, Banana, and Date survive. Cherry and Elderberry disappear. Clear the input and they're back. The filter runs on every keystroke, with zero delay.
Now toggle the "Try the naive useEffect approach" checkbox above the panel. Type a few characters quickly. See the flash? The stale indicator fires with specific numbers: "results show 5 items but query already matches 3." For a brief moment, the list and the query are out of sync.
Here's the naive approach that causes this:
Between the moment query updates and the effect fires, results holds the previous filter output. With five items, the demo amplifies this gap to make it visible — in production, the gap is one render frame, but with a thousand products and a loading spinner tied to result count, users would see a flash of wrong data.
Toggle the checkbox off. The stale indicator vanishes. The fix is to not store results at all:
This is derived state. Instead of maintaining two pieces of state that can drift apart, we compute results directly from query on every render. No effect, no sync bug, no stale frame. One piece of state, one computation, one truth.
When derived state is the wrong call: if filtering 10,000 items or running an expensive computation, you'd wrap this in useMemo to avoid recalculating on unrelated re-renders. For cheap, synchronous transformations like this — derive, don't sync.
04 — Highlight the matching text
Type "an" and look at the results. See how Banana has the matching substring highlighted? Filtering tells users which results survived. Highlighting tells them why.
Without it, a filtered list is a black box — the user types “an” and sees “Banana” but has to mentally scan the word to confirm the match. With highlighting, the connection between query and result is instant and visual.
Look at the state inspector — it shows the segment breakdown: segments("Banana"): B[an]ana. That's the exact output of the highlight function:
String surgery: find the match position with indexOf, slice the string into before/match/after, tag each piece. In the component, matched segments get a <mark> tag — semantically correct here, since <mark> means “text highlighted for reference.”
Now try typing "a" and look at “Banana” — only the first "a" lights up, even though there are three. indexOf returns the first match and stops. Highlighting every occurrence would need a different approach: String.split with a regex capture group, or a loop that finds all indices. That's a worthwhile extension — a single text.split(new RegExp(($), 'gi')) gives you all occurrences, but you'd need to escape regex metacharacters in the query first (characters like (, ., * are special in regex). The indexOf approach sidesteps that entire class of bugs.
05 — Handle the empty state
Type "xyz" into the search box.
The list shows a message: "No results for 'xyz'." Now toggle the "Empty State" feature off in the toolbar and type “xyz” again. The list simply vanishes — a blank void where five items used to be. As a user, this is disorienting. Did the app crash? Is the data gone? Did my query fail to send?
A blank space communicates nothing, and in UI design, communicating nothing is worse than communicating bad news.
Three lines of new code. The ternary does two things at once. It confirms the system received the input (not broken), and it frames zero results as a valid outcome (not a bug). Watch isEmpty flip to true in the state inspector, then clear the field — the full list returns instantly. This instant recovery tells the user the data isn't gone, just hidden. The search is a lens, not a destructive filter.
Toggle the feature back on and try progressively narrowing: type "a“ (3 results), ”an“ (1 result), ”anx“ (0 results, empty state). Each transition is smooth and communicative. Every state of your UI should say something deliberate — even ”I found nothing."
06 — Extract to a reusable hook
Last month you built this search for a fruit list. Next month you'll build it for a product catalog. The month after, a user directory. Each one starts as a copy-paste of the same useState + filter logic.
That's fine at first. Then someone discovers that queries with trailing spaces return zero results. You add a .trim() in the catalog. The user directory still has the bug. The fruit list has a different fix — someone lowercased the items at import time instead. Three copies that started identical have silently diverged, and no amount of grep will find all the places where “search filtering” lives because none of them are labeled.
Duplication isn't a problem because of the typing. It's a problem because of the drift.
Seven lines. The component collapses to a single destructure:
Look at the state inspector title — it now says "useSearch()" instead of “State.” The demo looks identical because the behavior hasn't changed. What changed is where the behavior lives, and that changes everything about how you work with it.
What custom hooks actually are
A custom hook is any function that starts with use and calls other hooks. That's the entire contract. The use prefix isn't a naming convention you could ignore — it's how React's linter knows to enforce the rules of hooks inside that function (no conditional calls, no calls outside components or other hooks). Remove the prefix and the linter goes silent, which means you lose the safety net.
What you get back is composition. useSearch is a building block that other hooks can wrap:
Notice what happened: we added debouncing without modifying useSearch. The original hook still works for cases where debouncing isn't needed. And any bug fix to the core filtering logic — like that .trim() — automatically propagates to both hooks.
The testing shift
Before extraction, testing the filter logic required mounting the entire component:
This works, but it tests filtering through the UI. If the test fails, is it the filter logic or the rendering? After extraction, you can test the logic directly:
No DOM, no selectors, no ambiguity about what broke. The component's own tests can now focus purely on rendering — does it show the right number of <li> elements given the results it receives?
This separation — what to compute vs how to render it — is the real lesson. Not the API of custom hooks, but the architectural instinct to extract behavior the moment it has a name.
When NOT to extract: if the logic is used in exactly one component and is tightly coupled to that component's rendering, extraction adds a file and an import without reducing complexity. Extract when the behavior has a name AND you can picture it being reused — even if that reuse hasn't happened yet. “Search filtering” clearly has a name. “The specific way this dialog calculates its z-index” probably doesn't.
07 — The component grows teeth
Your search works. Your boss is happy. Then the requirements start arriving.
Toggle Clear Button in the demo panel — a clear button appears next to the input. Watch the prop counter in the corner: 5 → 6. One prop, one feature. Reasonable.
Toggle Result Count — a count badge appears in the header. 6 → 7. Still fine.
Toggle Sort Toggle — an A⟶Z button appears. 7 → 9. Wait — that jumped by two. Sorting needs both a sortable boolean and a sortOrder state prop, because the parent needs to control whether sorting is active and in which direction.
Now type "a" in the search, click Clear, click A⟶Z. Every feature works. But look at the code that makes this work:
Count the conditional branches: showClear && query, showCount, sortable, capped.length > 0, showSkeleton. Five ifs hiding inside JSX. Each one is a fork in the render path, and each fork interacts with the others. What happens when showSkeleton is true but showCount is also true? Does the count show “0 results” while the skeleton animates? The component author has to decide, and the consumer has to discover that decision by reading the source.
This is what nine props costs. Not just the lines of code — the mental model. A developer using this component has to understand nine parameters and their interactions. The TypeScript types help, but they only tell you what's valid, not what's wise. showSkeleton={true} showCount={true} emptyMessage="Gone fishing" all type-check fine, but the visual result is probably not what anyone intended.
The user sees a better search. The developer sees a component that knows too much about every possible configuration.
This is the trajectory of every “flexible” component. Ant Design's Select has 40+ props. MUI's Autocomplete has 50+. They work — millions of apps use them — but adding the 51st prop means understanding how it interacts with the other 50. The component is configured, not composed. And that distinction is about to matter, because the next requirement isn't a feature — it's a layout change.
08 — Props aren't architecture
Here's where the monolith actually breaks. Not because of a bug, but because of a category of request that props can't handle gracefully.
Click "Count above input" in the layout picker below the panel. Watch the count badge move from the header to a standalone line above the search input — the panel actually rearranges. Now click "Sort in footer" — the sort button drops below the list.
Each layout works. But look at the message that appeared: "The layout changed — but it cost you a new prop." The prop counter keeps climbing. Each spatial rearrangement — moving an element from one place to another in the DOM — required a new configuration prop.
Why this is different from adding features
Steps 1-7 added behaviors: filtering, highlighting, clearing, sorting. Each behavior is a question with a yes/no answer: “Should this component sort?” That maps cleanly to a boolean prop.
But “where should the count badge appear?” isn't yes/no — it's a spatial relationship. And spatial relationships multiply combinatorially with every other element:
- Count can be: in the header, above the input, below the list, hidden
- Clear button can be: beside the input, inside the input, in the header, hidden
- Sort toggle can be: beside the input, in the header, in a footer, in a dropdown, hidden
That's 4 × 4 × 5 = 80 possible layouts from three elements. Each combination that a consumer requests, the component author must anticipate, implement, and test. And the 81st request — "put the count inside the input field, right-aligned" — requires modifying the component's internals, because the component owns the DOM structure.
Here's what it looks like inside the component when you start supporting position props. This is what the component author writes so the consumer can pass countPosition="above":
The same element — a result count — appears in two different places in the JSX, guarded by the same prop (countPosition) checked against different values. The clear button has three render sites. Each position variant is a new conditional branch that the component author must maintain, and every branch interacts with every other branch. Adding clearPosition="header" changes the spacing around the input, which changes how countPosition="header" looks, which means the component author needs to test every combination they claim to support.
This is the consumer's view of the same component:
Twelve props to describe a search box. And the consumer can't tell, from the props alone, whether clearPosition="inside" + countPosition="header" produces a sensible layout or a visual collision. The only way to know is to try it and look.
The render prop escape hatch (and why it's not enough)
You might think: "just add a renderItem prop and let the consumer customize the rendering." This is the render prop pattern, and it genuinely helps for content customization — the consumer controls what each list item looks like.
But render props don't solve the structural problem. renderItem lets you customize what's inside a list item, not where the list lives relative to the input. For that, you'd need renderHeader, renderFooter, renderInputArea... and now you're back to the same problem, just with functions instead of booleans.
The fundamental mismatch: props describe what a component does, not how it's arranged. Feature props (showClear, sortable) work because they toggle behavior. Layout props (clearPosition, countPosition) fail because they try to express structure through configuration, and structure has too many dimensions to enumerate.
Your designer says: “On the settings page, put the count above the input. On the dashboard, put the sort toggle in a bottom sheet. On mobile, hide the clear button but show a floating action button instead.” Each request is reasonable. Each request costs a new prop. And each prop interacts with every other prop in ways that are increasingly hard to predict.
There's a different abstraction that handles this — one where layout is expressed as structure, not configuration. React already has it: children for structure, and context for shared state.
09 — Compound components
Drag the components in the "Rearrange components" panel on the right. Move <Search.Count /> above <Search.Input />. Watch two things happen simultaneously: the JSX preview updates (the code lines animate to their new positions), and the live panel below rearranges to match.
No new props. No prop counter. No component library code was modified. The JSX tree is the layout.
Compare this to step 8. The same layout change that cost a countPosition prop now costs nothing — you move a line of JSX. Want the count in a sidebar? Move it to a different <div>. Want custom item rendering? Pass a different function to List. Want a completely novel layout no one anticipated? Go ahead — the components don't care where they sit in the tree.
How context makes this work
The trick is that Search.Root holds all shared state in a React context, and every child component pulls only what it needs:
That if (!ctx) guard is important — it turns a silent undefined into a clear error message. Without it, a <Search.Input /> rendered outside a <Search.Root> would fail with “Cannot read properties of null,” and the developer would have to trace back to figure out why.
Now each sub-component becomes self-contained:
Input doesn't know about Count. Count doesn't know about List. They're siblings that share state through a parent provider, not props passed through a chain. Each one answers a single question: “Given the current search state, what do I render?”
Why this solves what props couldn't
Go back to the 80-layout problem from step 8. With compound components, the consumer expresses each layout directly in JSX:
The library author didn't anticipate <MobileSearchHeader> or <BottomSheet>. They didn't need to — those are the consumer's components. The search sub-components work inside any wrapper because they don't know or care about their parent DOM structure. They only know about the context.
The dot-notation convention
Search.Root, Search.Input, Search.Count — the dot syntax looks like magic but it's a plain object:
This is purely organizational — it namespaces related components so you don't pollute the import scope with SearchRoot, SearchInput, SearchCount. The as const ensures TypeScript treats the object as readonly, which gives you autocomplete on Search. in your editor. You type Search. and see all available sub-components. That partially offsets the main tradeoff of this pattern.
The tradeoff: discoverability
With a monolithic component, you type <SearchableList and your editor shows you every prop — every capability is visible in one autocomplete popup. With compound components, you have to know that <Search.Count /> exists. The type system helps (Search. shows available components), but it can't show you example compositions — you need documentation or examples to understand which combinations make sense.
This is a real cost. Teams that adopt compound components without good documentation end up with developers who don't realize <Search.Empty> exists and hand-roll their own empty state. The pattern shifts complexity from the library author (who no longer maintains position props) to the documentation (which must now show composition examples).
The architecture insight
The deep principle is: state lives in context, structure lives in JSX, and no component knows about both. Root knows about state but accepts children without inspecting them. Input knows about structure (it renders an <input>) but gets its data from context. This separation is what makes the pattern flexible — the two concerns can vary independently.
This is the pattern behind Radix UI, Headless UI, Reach UI, and most component libraries worth studying. It's also the pattern behind HTML's own <select> and <option> — a parent that holds state, children that render options, and the browser's internal context connecting them.
When NOT to use compound components: when your component has a fixed layout that never needs to vary. A <Button> is always an icon, a label, and a container — compound components would add ceremony without flexibility. A <DatePicker> might benefit — if different teams need different calendar layouts, input formats, or popup positions. The trigger is not complexity or prop count, but structural variation. If nobody has ever asked “can I move X to a different position?”, you probably don't need this pattern yet.
Fruit List
- Apple
- Banana
- Cherry
- Date
- Elderberry