In the previous issue of The Pragmatic Developer, I shared a problem about partial duplication in our codebase. To address this, we need to separate the common parts from the differing ones. Today, I’ll demonstrate how to apply the Headless Component pattern to solve this issue in our ApprovalPanel component.
Background and Design Challenge
In our product, the Approval Panel component allows users to approve or reject requests. We encountered duplication issues when implementing variations of this component with different UI layouts. Our goal is to create a more flexible and reusable solution.
The pattern I’m using is the Headless Component pattern, which I previously discussed in a long-form article on MartinFowler.com. In this issue, I’ll show you how to apply this pattern to the ApprovalPanel component.
Firstly, thanks
for submitting another great gist on how he would refactor the code here. I’ll quickly walk through the key points in his implementation:const ApprovalPanel = ({ resourceId, actions }) => {
const { isDone, handleWorkflowAction } = useWorkflowActions(resourceId, actions);
// the rest of the approval panel
};
const useWorkflowActions = ({ resourceId, actions }) => {
const [isDone, setDone] = useState(false);
const handleWorkflowAction = (actionId) => {
const action = findActionById(actions, actionId);
if (!action) {
throw new Error(`Action '${actionId}' is not supported`);
}
// This could be extracted to some sort of repository or DAO 👇
const endpoint = buildWorkflowActionEndpoint(resourceId, action);
fetch("POST", endpoint)
.then((r) => r.json())
.then((data) => setDone(data.isDone));
};
return {
isDone,
handleWorkflowAction
};
};
const actions = [
{ id: APPROVE_ACTION, label: "Approve", path: '/approve' },
{ id: REJECT_ACTION, label: "Decline", path: '/reject' },
{ id: NEW_WORKFLOW_ACTION, label: "New action", path: '/new-action' }
];
// the usage of the refactored ApprovalPanel
<ApprovalPanel resourceId={resourceId} actions={actions} />
The key improvement is the useWorkflowActions
hook, which makes the system flexible and extendable. Users can add new actions without significantly altering the ApprovalPanel
. This decoupling is crucial to maintain a clean and modular design.
That said, I’d like to share my approach to this problem. The fundamental idea remains the same: separate the rendering logic from the calculation. In the React ecosystem, we can achieve this either by using a hook or by applying the render props pattern. I’ll demonstrate both methods.
Implementation 1: Extracting the Logic with React Hooks
A typical way to achieve this is by using React Hooks to handle state management and data fetching in a common place, while leaving the UI in a separate file. Here’s how we can implement it:
import { useState } from "react";
const useApproval = (id) => {
const [isDone, setDone] = useState(false);
const handleApprove = () => {
fetch(`/rest/approval/${id}/approve`, { method: "POST" })
.then((r) => r.json())
.then((data) => setDone(data.isDone));
};
const handleDecline = () => {
fetch(`/rest/approval/${id}/decline`, { method: "POST" })
.then((r) => r.json())
.then((data) => setDone(data.isDone));
};
return {
isDone,
handleApprove,
handleDecline,
};
};
export { useApproval };
Simplifying the UI Components
With the hook in place, we can simplify the ApprovalPanel and VerticalApprovalPanel components, removing duplication:
const ApprovalPanelWithHook = ({ id }) => {
const { isDone, handleApprove, handleDecline } = useApproval(id);
if (isDone) {
return <div>The request has been resolved</div>;
}
return (
<div className={container}>
<h2>This request requires your approval</h2>
<div className={classes.buttonsContainer}>
<Button onClick={handleApprove} appearance="Primary">
Approve
</Button>
<Button onClick={handleDecline}>Decline</Button>
</div>
</div>
);
};
Adapting the UI for Different Layouts
Since all state management is handled in the useApproval
hook, the UI component only needs to focus on rendering. This makes it easy to adapt the component for different layouts:
const ContextMenu = ({ id }) => {
const { isDone, handleApprove, handleDecline } = useApproval(id);
return (
<div className={menu}>
<ul>
<li onClick={handleClose}>Close</li>
{!isDone && (
<>
<li onClick={handleApprove}>Approve</li>
<li onClick={handleDecline}>Reject</li>
</>
)}
</ul>
</div>
);
};
In the context menu, for example, we only show the approve and reject options when the request is not done, displaying them as list items instead of buttons.
Implementation 2: Use Render Props Pattern
Another common way to implement the Headless Component pattern is by using render props, a common mechanism in React. This involves using a function as the children for any React component that returns JSX, allowing us to pass states into the children and share logic.
In our ApprovalPanel case, we can extract a component called ApprovalService
that passes down isDone
, handleApprove
, and handleDecline
to its children.
const ApprovalService = ({ id, children }) => {
const [isDone, setDone] = useState(false);
const handleApprove = () => {
fetch(`/rest/approval/${id}/approve`, { method: "POST" })
.then((r) => r.json())
.then((data) => setDone(data.isDone));
};
const handleDecline = () => {
fetch(`/rest/approval/${id}/decline`, { method: "POST" })
.then((r) => r.json())
.then((data) => setDone(data.isDone));
};
return <>{children({ isDone, handleApprove, handleDecline })}</>;
};
Then we can wrap the JSX with the ApprovalService
, like this:
const ApprovalPanel = ({ id }) => {
return (
<ApprovalService id={id}>
{({ isDone, handleApprove, handleDecline }) => {
if (isDone) {
return <div>The request has been resolved</div>;
}
return (
<div className={classes.container}>
<h2>This request requires your approval</h2>
<div className={classes.buttonsContainer}>
<Button onClick={handleApprove} appearance="Primary">
Approve
</Button>
<Button onClick={handleDecline}>Decline</Button>
</div>
</div>
);
}}
</ApprovalService>
);
};
In this example, ApprovalService
manages the state and logic, while the children function handles the rendering. This allows us to reuse the logic across different UI implementations without duplicating code.
Ending
By applying the Headless Component pattern, we’ve made our ApprovalPanel component more flexible and reusable. Try implementing this pattern in your own projects and see how it can simplify your code.
I hope you found this example useful. In the next issue, we’ll continue exploring more design patterns and practical solutions. As always, feel free to share your thoughts and solutions. Happy coding!