# When a Good Abstraction Starts to Rot (and How HOCs Saved It)

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](https://reactflow.dev/) - 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:

```typescript
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:

```typescript
// returns null if not in builder
const builderContext = useOptionalBuilder();
```

And then:

```typescript
{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.

![](https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExcDYzMWFucG90Zmhxa21xOXA5OHhsbncyZ3g0d2lqeDF6dHZ6ZGhkZCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/9058ZMj6ooluP4UUPl/giphy.gif align="center")

* * *

### **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
    

![](https://cdn.hashnode.com/uploads/covers/636f594ec83b2a23bd2ba94a/1a8976cc-7776-456d-964e-ab74d8ec87db.png align="center")

* * *

### **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.

```typescript
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:

```typescript
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.

![](https://cdn.hashnode.com/uploads/covers/636f594ec83b2a23bd2ba94a/2ce80e37-63c2-4ee8-982f-87a4a64828bc.png align="center")

* * *

### **Where This Becomes Really Nice**

In React Flow, nodes are registered via a `nodeTypes` map:

```typescript
const nodeTypes = {
  action: ActionNode,
  condition: ConditionNode,
};
```

So instead of changing the components, I changed how they're registered:

```typescript
const builderNodeTypes = Object.fromEntries(
  Object.entries(nodeTypes).map(([key, Component]) => [
    key,
    withBuilderNode(Component),
  ])
);
```

And then:

```typescript
// 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:

*   `useOptionalBuilder`
    
*   Hidden 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:

![](https://cdn.hashnode.com/uploads/covers/636f594ec83b2a23bd2ba94a/992df601-0764-4fe8-bb43-55597419eb87.png align="center")
