Components Are Just Sparkling Hooks

Here’s a question you might encounter while interviewing for React developer roles:
“What is the difference between a component and a hook?”
The answer that the interviewer is likely looking for is along the lines of “A component is a building block for the UI, and hooks are utility functions provided by React to manage state and side-effects 😴”.
It’s a satisfactory answer if your goal is to find employment. But if you want to make an impression, the more daring answer is: “There is no difference 🔥”.
Disclaimer: Daring answers to simple questions is an unwise strategy on screening rounds.
First, What is a Component?⌗
The very technical answer is that it is a function that returns a ReactNode
, which is anything that makes sense to render on-screen (including JSX, strings, numbers, nulls, and so on).
Below is a valid React component:
const MyComponent = () => {
return "Hello World!";
};
Components can render dynamic inputs using props…
const MyComponent = ({ name, onClick }) => {
return <button onClick={onClick}>Hello {name}!</button>;
};
…and implement stateful behaviors using hooks.
const MyCounter = () => {
const [count, increment] = useReducer((state) => state + 1, 0);
return <button onClick={increment}>Counter {count}</button>;
};
From there, the sky is the limit!
const MyFancyCounter = () => {
const [count, increment] = useReducer((state) => state + 1, 0);
// Add fancy styles every time `count` doubles
const fancyClass = useMemo(() => {
const orderOfMagnitude = Math.floor(Math.log2(count));
let style = {};
if (orderOfMagnitude <= 0) return "";
switch (orderOfMagnitude) {
case 1:
return "fancy-1";
case 2:
return "fancy-2";
case 3:
return "fancy-3";
default:
// Beyond 255
return "fanciest";
}
}, [count]);
return (
<button onClick={increment} className={fancyClass}>
Counter {count}
</button>
);
};
No matter what we do in the implementation, we can use it as a black box in another component by mixing it into the JSX.
const FancyCounterApp = () => {
return (
<div>
<h1>My Counter App</h1>
<MyFancyCounter />
</div>
);
};
Second, What are Hooks?⌗
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
Hooks are React built-in functions to manage state and effects. You can create new hooks by wrapping the React-provided hooks in a function.
Hooks can accept any input and return any value. The only restrictions are the rules of hooks, which dictate that hooks must not be called conditionally. Critically, those rules are transitive, meaning that anything that calls a hook is a hook itself and must follow those rules until it is used in a component.
This is where we get to the crux of the story: given that Components transitively inherit all restrictions of hooks (rules of hooks), and are more restrictive in their output (can only return ReactNodes
): Components are indeed a subtype of hooks. Any component can be used as a hook!
Case in point, our most complex component from earlier can become a hook effortlessly:
// It's prefixed by `use` now, its a hook!
const useFancyCounter = () => {
const [count, increment] = useReducer((state) => state + 1, 0);
// Add fancy styles every time `count` doubles
const fancyClass = useMemo(() => {
const orderOfMagnitude = Math.floor(Math.log2(count));
let style = {};
if (orderOfMagnitude <= 0) return "";
switch (orderOfMagnitude) {
case 1:
return "fancy-1";
case 2:
return "fancy-2";
case 3:
return "fancy-3";
default:
// Beyond 255
return "fanciest";
}
}, [count]);
return (
<button onClick={increment} className={fancyClass}>
Counter {count}
</button>
);
};
const FancyCounterApp = () => {
const myFancyCounter = useFancyCounter();
return (
<div>
<h1>My Counter App</h1>
{myFancyCounter}
</div>
);
};
So far, this might seem like uninteresting semantics, but there is a practical side to this exercise.
Headless Components⌗
As previously noted, the distinguishing trait of a component is that it returns a ReactNode
. For API purposes, the ReactNode
is a black box. Since useFancyCounter()
still returns a ReactNode
, its internal state is opaque and not consumable.
The limitation matters because it prevents FancyCounterApp
from easily implementing features such as:
- rendering the
{count}
outside of the<button>
- extending button styling
- adding another button to increment the count by two
- performing some event once the count reaches 10
Since useFancyCounter
is a hook, instead of returning a ReactNode
, it can expose its event handlers and state, which FancyCounterApp
can leverage into a more flexible output.
// It's prefixed by `use` now, its a hook!
const useFancyCounter = () => {
const [count, increment] = useReducer((state) => state + 1, 0);
// Add fancy styles every time `count` doubles
const fancyClass = useMemo(() => {
const orderOfMagnitude = Math.floor(Math.log2(count));
let style = {};
if (orderOfMagnitude <= 0) return "";
switch (orderOfMagnitude) {
case 1:
return "fancy-1";
case 2:
return "fancy-2";
case 3:
return "fancy-3";
default:
// Beyond 255
return "fanciest";
}
}, [count]);
return { fancyClass, increment, count };
};
useFancyCounter
is now a headless component because it implements all Component-adjacent functionality (event handling, state management, styling) but allows its caller to distill it into JSX instead of returning a pre-baked ReactNode
.

The flexibility of this pattern is unparalleled, with none of the boilerplate that a composition-based solution would’ve brought.
const FancyCounterApp = () => {
const { fancyClass, increment, count } = useFancyCounter();
// performing some event once the count reaches 10
useEffect(() => {
if (count === 10) {
showFireworks();
}
}, [count]);
return (
<div>
<h1>My Counter App</h1>
{/* rendering the `{count}` outside of the `<button>` */}
<h2>Count is at {count}</h2>
<button
onClick={increment}
// extend button styling
className={clsx(fancyClass, "some-other-class")}
>
Increment by 1
</button>
{/* adding another button to increment the count by two */}
<button
onClick={() => {
increment();
increment();
}}
>
Increment by 2
</button>
</div>
);
};
Unit Testing⌗
Headless components effectively decouple the view from the view model, making them testable individually.
// Testing full component
describe("FancyCounterApp", () => {
it("increments", () => {
render(<FancyCounterApp />);
const incrementBtn = screen.findByLabel("Increment by 1");
fireEvent.click(incrementBtn);
fireEvent.click(incrementBtn);
fireEvent.click(incrementBtn);
expect("Count is at 3").toBeInDocument();
});
});
// Testing component logic only
describe("useFancyCounter", () => {
it("increments", () => {
const { result } = renderHook(() => useFancyCounter());
act(() => result.current.increment());
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(3);
});
});
Composition + Headless Components⌗
While headless components often solve the same kind of challenges as composition, the two patterns are compatible (the implementation of the new components is left as an exercise to the reader):
const FancyCounterApp = () => {
return (
<FancyCounter.Context value={useFancyCounter()}>
<FancyCounter.Fireworks at={10} />
<div>
<h1>My Counter App</h1>
<h2>
Count is at <FancyCounter.Count />
</h2>
<FancyCounter.IncrementBtn by={1}>
Increment by 1
</FancyCounter.IncrementBtn>
<FancyCounter.IncrementBtn by={2}>
Increment by 2
</FancyCounter.IncrementBtn>
</div>
</FancyCounterContext>
);
};
This combined pattern is helpful if you want inversion of control while keeping the controller as an individual unit. The added benefit is the ability to reuse view-model logic between components that use context and those that don’t.
Go Deeper: A future article will be covering advanced composition patterns. Subscribe to the BBSS newsletter to be the first to read it!
State of the Art⌗
A few real-world libraries implement headless components (see: jxom/awesome-react-headless-components).
Some implement headless components exactly as described, using hooks and pushing them to their full potential (see: React Aria):
// https://react-spectrum.adobe.com/react-aria/useMenu.html#menubutton
function MenuButton<T extends object>(props: MenuButtonProps<T>) {
// Create state based on the incoming props
let state = useMenuTriggerState(props);
// Get props for the button and menu elements
let ref = React.useRef(null);
let { menuTriggerProps, menuProps } = useMenuTrigger<T>({}, state, ref);
return (
<>
<Button
{...menuTriggerProps}
buttonRef={ref}
style={{ height: 30, fontSize: 14 }}
>
{props.label}
<span aria-hidden="true" style={{ paddingLeft: 5 }}>
â–¼
</span>
</Button>
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement="bottom start">
<Menu {...props} {...menuProps} />
</Popover>
)}
</>
);
}
Others treat “headless” as a synonym for “unstyled”, and instead expose their state through through children
callbacks (see: Headless UI):
// https://headlessui.com/react/menu#using-render-props
function Example() {
return (
<Menu>
<MenuButton as={Fragment}>
{({ active }) => (
<button className={clsx(active && "bg-blue-200")}>My account</button>
)}
</MenuButton>
<MenuItems anchor="bottom">
{links.map((link) => (
<MenuItem key={link.href} as={Fragment}>
{({ focus }) => (
<a
className={clsx("block", focus && "bg-blue-100")}
href={link.href}
>
{link.label}
</a>
)}
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
The above is different because a UI component’s “head” is a renderable chunk. An unstyled ReactNode
is convenient for some purposes but does not address the same challenges as a hook-based headless component.
Conclusion⌗
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
It is easy to overlook the idea that components and hooks are not separate concepts, as they are always presented as distinct primitives.
Once accepted, headless components emerge as an obvious pattern that enables greater logic reusability, flexibility, and superior testing patterns.
If you enjoyed this article, you might also enjoy diving into the previous React article: Learning Suspense by Building a Suspense-Enabled Library.