Skip to main content

Command Palette

Search for a command to run...

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

When “shared” components stop being shared

Published
4 min read
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 - 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:

  • 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: