When Components Outgrow Their Looks
React's component model makes it easy to start simple, but UI controls rarely stay simple. A basic dropdown, for instance, begins as a clickable trigger and a list of options. Add accessibility requirements, keyboard navigation, async data, and theming variations, and the component quickly becomes a tangled bundle of state and markup. The more these concerns mix, the harder the component is to test, extend, or restyle for a different context.
The Headless Component pattern cuts through this by separating the component's logic from its visual presentation. The "head" — state management, event handling, and behavior — lives in a hook or non-visual component. The "body" — the JSX markup and styling — is left to the developer who consumes that hook. This separation makes the behavior reusable across any look and keeps the UI layer thin and declarative.
Building Up a Feature-Rich Dropdown
To see the pattern in action, consider a dropdown built from scratch. The starting point is straightforward: a trigger button and a state variable that controls whether the list panel is visible.
import { useState } from "react";
interface Item {
icon: string;
text: string;
description: string;
}
type DropdownProps = {
items: Item[];
};
const Dropdown = ({ items }: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
return (
<div className="dropdown">
<div className="trigger" tabIndex={0} onClick={() => setIsOpen(!isOpen)}>
<span className="selection">
{selectedItem ? selectedItem.text : "Select an item..."}
</span>
</div>
{isOpen && (
<div className="dropdown-menu">
{items.map((item, index) => (
<div
key={index}
onClick={() => setSelectedItem(item)}
className="item-container"
>
<img src={item.icon} alt={item.text} />
<div className="details">
<div>{item.text}</div>
<small>{item.description}</small>
</div>
</div>
))}
</div>
)}
</div>
);
};
As the component is broken into smaller pieces, the separation of roles becomes clear. A Trigger component only needs a label and a click handler; it stays agnostic to the surrounding logic.
const Trigger = ({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) => {
return (
<div className="trigger" tabIndex={0} onClick={onClick}>
<span className="selection">{label}</span>
</div>
);
};
A DropdownMenu component similarly renders the list of items, takes an onItemClick callback, and knows nothing about how that callback originated.
const DropdownMenu = ({
items,
onItemClick,
}: {
items: Item[];
onItemClick: (item: Item) => void;
}) => {
return (
<div className="dropdown-menu">
{items.map((item, index) => (
<div
key={index}
onClick={() => onItemClick(item)}
className="item-container"
>
<img src={item.icon} alt={item.text} />
<div className="details">
<div>{item.text}</div>
<small>{item.description}</small>
</div>
</div>
))}
</div>
);
};
These child components are then wired together in the main Dropdown, which passes down its state as props.
const Dropdown = ({ items }: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
return (
<div className="dropdown">
<Trigger
label={selectedItem ? selectedItem.text : "Select an item..."}
onClick={() => setIsOpen(!isOpen)}
/>
{isOpen && <DropdownMenu items={items} onItemClick={setSelectedItem} />}
</div>
);
};
This decomposition helps, but real-world requirements add pressure. Keyboard navigation is a prime example: supporting Enter to toggle the menu, arrow keys to move through items, and Escape to close. All of that needs to be tracked with new state like the highlighted index, and wired through the same component.
const Dropdown = ({ items }: DropdownProps) => {
// ... previous state variables ...
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
// ... case blocks ...
// ... handling Enter, Space, ArrowDown and ArrowUp ...
}
};
return (
<div className="dropdown" onKeyDown={handleKeyDown}>
{/* ... rest of the JSX ... */}
</div>
);
};
The menu now needs that index as a prop so it can style the highlighted item and set the appropriate aria-selected attribute.
const DropdownMenu = ({
items,
selectedIndex,
onItemClick,
}: {
items: Item[];
selectedIndex: number;
onItemClick: (item: Item) => void;
}) => {
return (
<div className="dropdown-menu" role="listbox">
{/* ... rest of the JSX ... */}
</div>
);
};
At this stage, the Dropdown component is weighed down. It holds selectedItem, selectedIndex, their setters, a key handler, and renders the trigger and menu. The component has become a monolith of behavior and presentation.
Extracting the Head with a Custom Hook
Moving the behavior into a custom hook flips the structure. The useDropdown hook becomes the headless component — it owns the open/close state, the selected and highlighted items, and the keyboard event handler. What it returns is a clean interface for any view layer to consume.
const useDropdown = (items: Item[]) => {
// ... state variables ...
// helper function can return some aria attribute for UI
const getAriaAttributes = () => ({
role: "combobox",
"aria-expanded": isOpen,
"aria-activedescendant": selectedItem ? selectedItem.text : undefined,
});
const handleKeyDown = (e: React.KeyboardEvent) => {
// ... switch statement ...
};
const toggleDropdown = () => setIsOpen((isOpen) => !isOpen);
return {
isOpen,
toggleDropdown,
handleKeyDown,
selectedItem,
setSelectedItem,
selectedIndex,
};
};
The Dropdown component itself is reduced to a thin wrapper. It calls the hook and spreads the returned state and handlers into its own JSX, preserving full control over the visual output while delegating all behavior to the hook.
const Dropdown = ({ items }: DropdownProps) => {
const {
isOpen,
selectedItem,
selectedIndex,
toggleDropdown,
handleKeyDown,
setSelectedItem,
} = useDropdown(items);
return (
<div className="dropdown" onKeyDown={handleKeyDown}>
<Trigger
onClick={toggleDropdown}
label={selectedItem ? selectedItem.text : "Select an item..."}
/>
{isOpen && (
<DropdownMenu
items={items}
onItemClick={setSelectedItem}
selectedIndex={selectedIndex}
/>
)}
</div>
);
};
The result is a clear division of responsibilities: all core dropdown logic — which item is selected, which is highlighted, what happens when the user presses an arrow key — lives in one testable place, independent of whether the dropdown is rendered as a list, a popover, or a custom mobile picker. Adding a new feature means updating the hook or the view, but rarely both at once.
Testing Headless Component Behavior
Because the core logic sits in one place, testing it is straightforward. State transitions can be verified by calling a public method and checking the resulting state. For instance, we can confirm the link between toggleDropdown and the isOpen property:
const items = [{ text: "Apple" }, { text: "Orange" }, { text: "Banana" }];
it("should handle dropdown open/close state", () => {
const { result } = renderHook(() => useDropdown(items));
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.toggleDropdown();
});
expect(result.current.isOpen).toBe(true);
act(() => {
result.current.toggleDropdown();
});
expect(result.current.isOpen).toBe(false);
});
Keyboard interaction requires a more integrated test due to the lack of a visual DOM. Building a small stand-in component for tests serves two purposes: it validates behavior through real JSX and doubles as a usage example. Here, an integration test replaces the direct state assertion:
it("trigger to toggle", async () => {
render(<SimpleDropdown />);
const trigger = screen.getByRole("button");
expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
const list = screen.getByRole("listbox");
expect(list).toBeInTheDocument();
await userEvent.click(trigger);
expect(list).not.toBeInTheDocument();
});
The SimpleDropdown used in this test is a fake—an object that encapsulates access to an external system—created purely to verify logic without scattering adoption code through the codebase.
const SimpleDropdown = () => {
const {
isOpen,
toggleDropdown,
selectedIndex,
selectedItem,
updateSelectedItem,
getAriaAttributes,
dropdownRef,
} = useDropdown(items);
return (
<div
tabIndex={0}
ref={dropdownRef}
{...getAriaAttributes()}
>
<button onClick={toggleDropdown}>Select</button>
<p>{selectedItem?.text}</p>
{isOpen && (
<ul role="listbox">
{items.map((item, index) => (
<li
key={index}
role="option"
aria-selected={index === selectedIndex}
onClick={() => updateSelectedItem(item)}
>
{item.text}
</li>
))}
</ul>
)}
</div>
);
};
That dummy component wires useDropdown into a minimal UI: a "Select" button toggles a list with Apple, Orange, and Banana items. The test covering keyboard selection renders SimpleDropdown, clicks its trigger, moves focus with arrow-down, selects with enter, and checks that the chosen value appears.
it("select item using keyboard navigation", async () => {
render(<SimpleDropdown />);
const trigger = screen.getByRole("button");
expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
const dropdown = screen.getByRole("combobox");
dropdown.focus();
await userEvent.type(dropdown, "{arrowdown}");
await userEvent.type(dropdown, "{enter}");
await expect(screen.getByTestId("selected-item")).toHaveTextContent(
items[0].text
);
});
Custom hooks are the common implementation route, but not the only one. Before hooks, render props and Higher-Order Components filled the same role. A declarative API built on React context remains a popular alternative.
Declarative API via Context
The context approach establishes a component hierarchy where each part is replaceable. HeadlessDropdownUsage accepts an items array and composes a Dropdown from Dropdown.Trigger, Dropdown.List, and Dropdown.Option children:
import { HeadlessDropdown as Dropdown } from "./HeadlessDropdown";
const HeadlessDropdownUsage = ({ items }: { items: Item[] }) => {
return (
<Dropdown items={items}>
<Dropdown.Trigger as={Trigger}>Select an option</Dropdown.Trigger>
<Dropdown.List as={CustomList}>
{items.map((item, index) => (
<Dropdown.Option
index={index}
key={index}
item={item}
as={CustomListItem}
/>
))}
</Dropdown.List>
</Dropdown>
);
};
These subcomponents provide unstyled defaults (button, ul, li) and each takes an as prop so users can inject custom components with their own styles:
const CustomTrigger = ({ onClick, ...props }) => (
<button className="trigger" onClick={onClick} {...props} />
);
const CustomList = ({ ...props }) => (
<div {...props} className="dropdown-menu" />
);
const CustomListItem = ({ ...props }) => (
<div {...props} className="item-container" />
);
The implementation is not complicated. A context is defined on the root Dropdown, state lives there, and child nodes access or mutate that state through the same context:
type DropdownContextType<T> = {
isOpen: boolean;
toggleDropdown: () => void;
selectedIndex: number;
selectedItem: T | null;
updateSelectedItem: (item: T) => void;
getAriaAttributes: () => any;
dropdownRef: RefObject<HTMLElement>;
};
function createDropdownContext<T>() {
return createContext<DropdownContextType<T> | null>(null);
}
const DropdownContext = createDropdownContext();
export const useDropdownContext = () => {
const context = useContext(DropdownContext);
if (!context) {
throw new Error("Components must be used within a <Dropdown/>");
}
return context;
};
That snippet sets a generic DropdownContextType and a factory to create the context. useDropdownContext enforces that it is used only beneath a <Dropdown/> root. The provider component then manages state and exposes ARIA attributes on its wrapper div:
const HeadlessDropdown = <T extends { text: string }>({
children,
items,
}: {
children: React.ReactNode;
items: T[];
}) => {
const {
//... all the states and state setters from the hook
} = useDropdown(items);
return (
<DropdownContext.Provider
value={{
isOpen,
toggleDropdown,
selectedIndex,
selectedItem,
updateSelectedItem,
}}
>
<div
ref={dropdownRef as RefObject<HTMLDivElement>}
{...getAriaAttributes()}
>
{children}
</div>
</DropdownContext.Provider>
);
};
Notice that it reuses the useDropdown hook from the hook-based version, passing values down through the context provider. The child components—HeadlessDropdown.Trigger, HeadlessDropdown.List, and HeadlessDropdown.Option—consume that context and render through the as prop:
HeadlessDropdown.Trigger = function Trigger({
as: Component = "button",
...props
}) {
const { toggleDropdown } = useDropdownContext();
return <Component tabIndex={0} onClick={toggleDropdown} {...props} />;
};
HeadlessDropdown.List = function List({
as: Component = "ul",
...props
}) {
const { isOpen } = useDropdownContext();
return isOpen ? <Component {...props} role="listbox" tabIndex={0} /> : null;
};
HeadlessDropdown.Option = function Option({
as: Component = "li",
index,
item,
...props
}) {
const { updateSelectedItem, selectedIndex } = useDropdownContext();
return (
<Component
role="option"
aria-selected={index === selectedIndex}
key={index}
onClick={() => updateSelectedItem(item)}
{...props}
>
{item.text}
</Component>
);
};
HeadlessDropdown.Triggersupplies a button that toggles the list.HeadlessDropdown.Listshows a container only while open.HeadlessDropdown.Optionrenders items and records selection on click.
Which style to adopt is a matter of preference. Hooks stay clear of virtual DOM interaction entirely—the only link between shared logic and UI is a ref. The context variant, meanwhile, ships usable defaults when the user is happy with standard elements.
Swapping the UI Without Touching Logic
The payoff appears when a new design arrives. Suppose the next iteration needs a button trigger and avatar thumbnails per option. Because state and behavior already live in useDropdown, the only work is presentational:
const DropdownTailwind = ({ items }: DropdownProps) => {
const {
isOpen,
selectedItem,
selectedIndex,
toggleDropdown,
handleKeyDown,
setSelectedItem,
} = useDropdown<Item>(items);
return (
<div
className="relative"
onClick={toggleDropdown}
onKeyDown={handleKeyDown}
>
<button className="btn p-2 border ..." tabIndex={0}>
{selectedItem ? selectedItem.text : "Select an item..."}
</button>
{isOpen && (
<ul
className="dropdown-menu ..."
role="listbox"
>
{(items).map((item, index) => (
<li
key={index}
role="option"
>
{/* ... rest of the JSX ... */}
</li>
))}
</ul>
)}
</div>
);
};
In that version, Tailwind CSS handles the styling. The structure changes—a different trigger, an image beside each option—but the hook-driven state and interactions do not. React Devtools shows the same state collection in the hooks section, and every dropdown, regardless of appearance, relies on the identical internal behavior. That consistency extends past static list items into asynchronous scenarios.
Extending to Remote Data
Fetching from a server introduces three additional states: loading, error, and data. A typical first approach wires those into a component useEffect:
//...
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<Item[] | null>(null);
const [error, setError] = useState<Error | undefined>(undefined);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch("/api/users");
if (!response.ok) {
const error = await response.json();
throw new Error(`Error: ${error.error || response.status}`);
}
const data = await response.json();
setData(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
//...
That snippet sets a loading flag, fetches from "/api/users", clears the flag on completion, and stores either the payload or the failure. The logic works but sits inside the UI. We can push the Headless-style separation further by pulling that process out.
After extracting the fetch into a fetchUsers function, the next step is a generic hook that accepts a fetch function and owns the loading, error, and data bookkeeping:
const fetchUsers = async () => {
const response = await fetch("/api/users");
if (!response.ok) {
const error = await response.json();
throw new Error('Something went wrong');
}
return await response.json();
};
The result is the useService hook—an abstraction reusable across the app for any remote request:
const useService = <T>(fetch: () => Promise<T>) => {
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | undefined>(undefined);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const data = await fetch();
setData(data);
} catch(e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
fetchData();
}, [fetch]);
return {
loading,
error,
data,
};
}
The dropdown can now consume it at the call site:
// fetch products
const { loading, error, data } = useService(fetchProducts);
// or other type of resources
const { loading, error, data } = useService(fetchTickets);
State management stays out of the presentation layer, the service hook is reusable anywhere, and future interface changes remain focused on components rather than the behavior underneath.
Dropdown Without the Drag
Adding remote data fetching doesn't force complexity into the Dropdown component. The logic lives in the useService and useDropdown hooks, so the component itself stays small. It delegates fetch state management to useService, delegates interaction state to useDropdown, and uses a renderContent helper to decide what to show while loading, on error, or with data.
const Dropdown = () => {
const { data, loading, error } = useService(fetchUsers);
const {
toggleDropdown,
dropdownRef,
isOpen,
selectedItem,
selectedIndex,
updateSelectedItem,
getAriaAttributes,
} = useDropdown<Item>(data || []);
const renderContent = () => {
if (loading) return <Loading />;
if (error) return <Error />;
if (data) {
return (
<DropdownMenu
items={data}
updateSelectedItem={updateSelectedItem}
selectedIndex={selectedIndex}
/>
);
}
return null;
};
return (
<div
className="dropdown"
ref={dropdownRef as RefObject<HTMLDivElement>}
{...getAriaAttributes()}
>
<Trigger
onClick={toggleDropdown}
text={selectedItem ? selectedItem.text : "Select an item..."}
/>
{isOpen && renderContent()}
</div>
);
};
This arrangement is a practical demonstration of loose coupling. Because the UI is separate from the behavior, you can swap pieces freely. A UserDropdown can use plain JSX and default styling, while a ProductDropdown pulls from a different endpoint and renders with TailwindCSS—both sharing the same underlying logic and common Loading/Error components.
What a Headless Component Really Is
The Headless Component pattern is a way to separate JSX from state management. Writing declarative UI is the easy part; managing state is where complexity hides. A headless component absorbs that complexity—it's a function or object containing logic that renders nothing itself, leaving all visual output to the consumer. That gives you maximum flexibility to reuse complex logic across very different visual representations.
function useDropdownLogic() {
// ... all the dropdown logic
return {
// ... exposed logic
};
}
function MyDropdown() {
const dropdownLogic = useDropdownLogic();
return (
// ... render the UI using the logic from dropdownLogic
);
}
The benefits are clear: logic encapsulation makes components highly reusable (DRY), separating concerns keeps code maintainable, and the same core logic can adapt to different design systems or frameworks. But the pattern deserves thoughtful application. Developers unfamiliar with it will face a learning curve, and if overused, the added abstraction can make code harder to read by inserting an extra layer of indirection.
This approach is not React-exclusive. Vue has a comparable idea called a renderless component, which similarly separates logic and state from the UI layer. Whether frameworks like Angular support it cleanly is less certain, though the underlying principle worth evaluating in your own context.
Old Roots, New Soil
If the headless pattern feels familiar, that's because it is. Experienced GUI developers will recognize it under names like View-Model in MVVM or Martin Fowler's Presentation Model, which he described years ago in a broader article on UI architectures.
Presentation Model abstracts the state and behavior of the view into a model class within the presentation layer. This model coordinates with the domain layer and provides an interface to the view, minimizing decision-making in the view...
Yet the pattern deserves a fresh look for the web. Some of the pressures that shaped MVC-era architectures don't apply here. The original push to separate UI from logic often came from testing constraints—headless CI/CD environments couldn't easily exercise desktop UI. React avoids that bottleneck: tools like jsdom render and test DOM behavior in-memory on any server. Real browser tests run through Cypress on headless Chrome, something desktop apps couldn't do half a century ago.
Another old challenge—data synchronization across multiple views—has also faded. In traditional desktop apps, one model might feed a table, a chart, and a heatmap simultaneously. The developer had to wire up listeners to refresh each view after a data mutation. With React's unidirectional data flow, you treat any change as a new instance and re-render wholesale. This bypasses the need to register listeners to propagate model changes (leaving aside the virtual DOM and reconciliation details, which simplify the picture further).
The Headless Component isn't a new architectural invention. It's an implementation strategy within a component-based framework. Keeping logic out of the view still matters—for clear responsibilities and for swapping one view for another.
The Ecosystem Has Already Embraced It
Headless components have been quietly thriving inside popular libraries you may already use:
- React ARIA: Adobe's set of hooks for accessibility, handling keyboard interaction, focus, and ARIA attributes so you can build accessible React interfaces.
- Headless UI: An unstyled, accessible component library that pairs with Tailwind CSS—it supplies behavior while you handle your own markup.
- React Table: Hooks for fast, customizable tables and datagrids—a utility that defers all visual output to you.
- Downshift: Handles the logic for accessible dropdowns and comboboxes, letting you style them as you see fit.
Each of these encapsulates complex interaction logic behind a set of primitives. For production projects, they are almost always more robust than a hand-rolled version. The pattern itself teaches you how to approach state and logic separation; libraries like these show the approach done right.
Final Thought
(No summary needed—the article itself is already succinct.)



