When it comes to front-end development, terms like ‘headless UI’ or ‘headless components’ can be confusing. If you’re unsure about what they mean, don’t worry – you’re not alone. Despite their perplexing names, these concepts are actually powerful strategies that can greatly simplify the management of complex user interfaces.
While headless components may seem obscure, their real strength lies in their flexibility, potential for reusability, and their ability to enhance the organization and cleanliness of your codebase. In this article, we will demystify this pattern and shed light on what it exactly entails, why it is beneficial, and how it can revolutionize your approach to interface design.
To illustrate this, let’s start by exploring a simple yet effective application of headless components: extracting a useToggle
hook from two similar components to reduce code duplication. Although this example may appear trivial, it lays the foundation for understanding the core principles of headless components. By identifying common patterns and extracting them into reusable parts, we can streamline our codebase and enable a more efficient development process.
But that’s just the beginning! As we dive deeper, we will encounter a more complex implementation of this principle in action: leveraging Downshift, a powerful library for creating enhanced input components.
By the end of this article, my goal is not only to provide you with an understanding of headless components but also to give you the confidence to integrate this powerful pattern into your own projects. So let’s dispel the confusion and embrace the transformative potential of headless components.
A Toggle component
Let’s start by examining a Toggle component. Toggles play a vital role in numerous applications, enabling functions like “remember me on this device,” “activate notifications,” or the popular “dark mode.”
Now, let’s move on to a ToggleButton component. Creating a toggle in React is surprisingly straightforward. Here’s how we can build one:
const ToggleButton = () => {
const [isToggled, setIsToggled] = useState(false);
const toggle = useCallback(() => {
setIsToggled((prevState) => !prevState);
}, []);
return (
<div className="toggleContainer">
<p>Do not disturb</p>
<button onClick={toggle} className={isToggled ? "on" : "off"}>
{isToggled ? "ON" : "OFF"}
</button>
</div>
);
};
In this code, we use the useState
hook to set up a state variable called isToggled
with an initial value of false
. The toggle
function, created with useCallback
, toggles the isToggled
value between true
and false
each time it’s called (triggered by a button click). The appearance and text of the button dynamically reflect the isToggled
state.
Now, let’s say we need to build another component called ExpandableSection
. This component shows or hides detailed information for a section, with a button next to the heading that allows users to expand or collapse the details.
const ExpandableSection = ({ title, children }: ExpandableSectionType) => {
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = useCallback(() => {
setIsOpen((prevState) => !prevState);
}, []);
return (
<div>
<h2 onClick={toggleOpen}>{title}</h2>
{isOpen && <div>{children}</div>}
</div>
);
};
In this code, we again use the useState
hook to set up a state variable called isOpen
with an initial value of false
. The toggleOpen
function, created with useCallback
, toggles the isOpen
value between true
and false
. When isOpen
is true
, the detailed information is displayed.
There’s an obvious similarity between the ‘on’ and ‘off’ states in the ToggleButton
component and the expand
and collapse
actions in the ExpandableSection
component. Recognizing this commonality, we can abstract this shared functionality into a separate function. In React, we achieve this by creating a custom hook called useToggle
.
const useToggle = (init = false) => {
const [state, setState] = useState(init);
const toggle = useCallback(() => {
setState((prevState) => !prevState);
}, []);
return [state, toggle];
};
This refactoring may seem simple, but it emphasizes an important concept: separating behavior from presentation. In this scenario, our custom hook serves as a state machine independent of JSX. Both ToggleButton
and ExpandableSection
leverage this underlying logic.
Those who have worked on mid-scale frontend projects know that the majority of updates or bugs are related to managing the UI’s state, rather than the visuals. Hooks provide a powerful tool for centralizing this logical aspect, making it easier to scrutinize, optimize, and maintain.
The headless component
Now, let’s explore the concept of a headless component. Many great libraries already follow this pattern of separating behavior or state management from presentation. One of the most famous is Downshift.
Downshift applies the concept of headless components, which are components that manage behavior and state without rendering any UI. They provide a state and a set of actions in their render prop function, allowing you to connect them to your UI. This way, Downshift empowers you to control your UI while it takes care of the complex state and accessibility management.
For example, let’s say we want to build a dropdown list. Obviously, we need list data, a trigger, and a few customizations for highlighting selected items and determining the number of visible lines. However, we don’t want to build the accessibility from scratch, considering the various edge cases across different browsers and devices.
const StateSelect = () => {
const {
isOpen,
selectedItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
highlightedIndex,
getItemProps,
} = useSelect({items: states});
return (
<div>
<label {...getLabelProps()}>Issued State:</label>
<div {...getToggleButtonProps()} className="trigger" >
{selectedItem ?? 'Select a state'}
</div>
<ul {...getMenuProps()} className="menu">
{isOpen &&
states.map((item, index) => (
<li
style={
highlightedIndex === index ? {backgroundColor: '#bde4ff'} : {}
}
key={`${item}${index}`}
{...getItemProps({item, index})}
>
{item}
</li>
))}
</ul>
</div>
)
}
In this code, we use the useSelect
hook from Downshift to create a state selector. It allows users to select a state from a dropdown menu. The hook manages the state and interactions for the select input, providing variables like isOpen
, selectedItem
, and highlightedIndex
. It also offers functions like getToggleButtonProps
, getLabelProps
, getMenuProps
, and getItemProps
to provide necessary props to the corresponding elements.
This approach gives you complete control over the rendering, allowing you to style your components to fit your application’s look and feel and apply custom behavior when needed. It also facilitates sharing behavior logic across different components or projects.
There are several other headless component libraries that follow this pattern, such as Reakit, React Table, and react-use.
A balanced view
As we continue to deliberately separate logic from the UI, we create a layered structure that enhances code clarity and maintainability. This layered approach involves JSX at the highest layer, a ‘headless component’ right below it, which handles behavior and state, and data models at the base, focusing on domain-specific logic and data management. This separation of concerns allows for a neat and organized codebase.
Now, let’s consider the benefits and considerations of using the headless UI pattern. Headless components offer reusability, separation of concerns, flexibility, and testability. However, they may introduce initial overhead, require a learning curve, can be overused, and may have potential performance issues if not handled carefully. It’s important to evaluate these factors based on your project’s needs and complexity before adopting the headless UI pattern.
In summary, this article dived into the world of Headless User Interfaces, demonstrating how separating behavior from rendering leads to more maintainable and reusable code. We explored simple and complex examples, showcasing the power of headless components in reducing redundancy and bugs. By understanding the ‘headless’ approach, you can leverage this pattern to create scalable and maintainable UIs in your future projects.