When a Good Abstraction Starts to Rot (and How HOCs Saved It)
When “shared” components stop being shared

Recently at work, I found myself fighting an abstraction that used to feel perfectly fine.
We're working on a visually complex feature built with React Flow - a graph of nodes and edges that represents workflows.
The same nodes are rendered in two different places:
Builder view (where users can edit the workflow)
Run view (where users just observe execution)
At some point, I needed to add a small feature to the builder: a toolbar on each node (delete, test, etc.).
Sounds harmless.
It wasn’t.
The Problem: A “Shared” Component That Isn’t Really Shared
We had a component that acted as a base UI for all nodes:
export function WorkflowNode({ title, icon, status, children }) {
return (
<div className="node">
<div className="header">
{icon}
{title}
</div>
{children}
</div>
);
}
Clean. Simple. Reusable.
But then came the toolbar.
The builder view is wrapped with a React context (for things like executing nodes, deleting them, etc.). The run view isn't.
Normally, calling the builder hook outside that context would throw - so I introduced a “safe” version:
// returns null if not in builder
const builderContext = useOptionalBuilder();
And then:
{builderContext && (
<NodeToolbar>
<button onClick={handleTest}>Test</button>
<button onClick={handleDelete}>Delete</button>
</NodeToolbar>
)}
And just like that, I invented a hook just to make the abstraction pretend it still works.
Why This Felt Wrong
This component was supposed to be:
“A visual shell for a node”
But now it also:
Knows about a builder context
Handles business logic (delete, test)
Renders builder-only UI
And in the run view? All of this becomes dead code. It technically works, but it's misleading.
This is how abstractions start to rot:
You add one condition
Then another
Then a hook to support the condition
And suddenly your “shared” component isn't really shared anymore
The Key Insight
The problem wasn't what I added - it was where I added it.
I didn't actually want to change the node, I wanted to change how the node behaves in a specific context.
Enter: Higher-Order Components (HOC)
A Higher-Order Component (HOC) is just:
A function that takes a component and returns a new component with additional behavior.
function withSomething(WrappedComponent) {
return function EnhancedComponent(props) {
return <WrappedComponent {...props} />;
};
}
That's it. No magic - just composition.
Applying It to Our Case
Instead of modifying WorkflowNode, I created a wrapper:
export function withBuilderNode(WrappedNode) {
return function BuilderNode(props) {
const { id } = props;
return (
<>
<NodeToolbar>
<button onClick={() => console.log('test', id)}>Test</button>
<button onClick={() => console.log('delete', id)}>Delete</button>
</NodeToolbar>
<WrappedNode {...props} />
</>
);
};
}
Now the node itself stays clean.
Where This Becomes Really Nice
In React Flow, nodes are registered via a nodeTypes map:
const nodeTypes = {
action: ActionNode,
condition: ConditionNode,
};
So instead of changing the components, I changed how they're registered:
const builderNodeTypes = Object.fromEntries(
Object.entries(nodeTypes).map(([key, Component]) => [
key,
withBuilderNode(Component),
])
);
And then:
// Builder view
<ReactFlow nodeTypes={builderNodeTypes} />
// Run view
<ReactFlow nodeTypes={nodeTypes} />
Same nodes. Different behavior. No conditionals inside.
What We Gained
This small shift gave a much cleaner separation:
| Concern | Where it lives |
|---|---|
| Visual node structure | WorkflowNode |
| Node-specific logic | ActionNode, ConditionNode etc. |
| Builder-only UI (toolbar) | withBuilderNode |
| Run view | untouched |
No more:
useOptionalBuilderHidden conditionals
Leaky abstractions
Why I Like This Pattern
What I like about this solution is that it doesn't try to be clever.
It just respects boundaries:
Components describe what they are
Wrappers describe how they behave in a context
And since React Flow already uses nodeTypes as an extension point, this fits naturally into the architecture.
Final Thought
This wasn’t really about toolbars or even React Flow.
It was about catching the moment when:
“This abstraction still works… but it's starting to lie.”
And instead of patching it with another condition, stepping back and asking:
“Am I solving this in the right layer?”
Another unrelated (but amusing) note - here's what Gemini came up with when I asked it to generate a visual for this post:


