Blog · Engineering

The Composable Edit Pattern: Building Primitives That Compose

· December 20, 2025 · 6 min read

How we built a headless useInlineEdit hook and EditOverlay wrapper—two focused primitives that make any component editable.

Aristotle observed that excellence is a habit. In software, we might say: good architecture is a set of primitives that compose naturally into whatever you need next.

Last week, we built an inline editing system for our product configuration UI. Click a status badge, type a new value, press Enter. The value updates. The status check turns green.

This post describes the two primitives we created—a headless hook and a visual wrapper—and why this combination makes inline editing trivially easy to add anywhere.

I. The Goal: Editable Status Items

Our product mapping page displays configuration checks: “Export Unit”, “Scaling Factor”, “Reference Price”. Each check shows a ✓ or ✗ depending on whether the field is configured.

We wanted users to edit these values directly—hover, click, type, save. The status check updates immediately based on the new value.

The solution needed three properties:

  1. Composable: Works with any presentation component (CheckItem, TaxItem, Field).
  2. Keyboard-friendly: Escape cancels, Enter saves.
  3. Isolated: Editing one field does not re-render others.

II. Primitive One: useInlineEdit

A headless hook that encapsulates the edit state machine.

const exportUnitEdit = useInlineEdit({
  initialValue: productMap.exportUnit || '',
  onSave: (val) => setEditedValues(prev => ({ ...prev, exportUnit: val }))
});

The hook provides:

PropertyPurpose
isEditingBoolean: are we in edit mode?
draftThe current working value
start()Enter edit mode, populate draft
cancel()Exit edit mode, discard changes
save()Exit edit mode, call onSave with draft
inputPropsSpread onto an <Input>: includes value, onChange, onKeyDown, autoFocus

The keyboard handling lives inside inputProps.onKeyDown:

if (e.key === 'Escape') cancel();
if (e.key === 'Enter') save();

Each field instantiates its own hook. Each hook manages its own state. When one field enters edit mode, no other component re-renders—React’s reconciliation naturally isolates updates.

The Full Hook

export function useInlineEdit<T extends string | number>({
  initialValue,
  onSave,
}: { initialValue: T; onSave?: (value: T) => void }) {
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState<T>(initialValue);

  // Sync draft when external value changes (e.g., after server save)
  useEffect(() => {
    if (!isEditing) setDraft(initialValue);
  }, [initialValue, isEditing]);

  const start = useCallback(() => {
    setDraft(initialValue);
    setIsEditing(true);
  }, [initialValue]);

  const cancel = useCallback(() => {
    setDraft(initialValue);
    setIsEditing(false);
  }, [initialValue]);

  const save = useCallback(() => {
    onSave?.(draft);
    setIsEditing(false);
  }, [draft, onSave]);

  return {
    isEditing,
    draft,
    start,
    cancel,
    save,
    inputProps: {
      value: draft,
      onChange: (e) => setDraft(e.target.value as T),
      onKeyDown: (e) => {
        if (e.key === 'Escape') { e.preventDefault(); cancel(); }
        if (e.key === 'Enter') { e.preventDefault(); save(); }
      },
      autoFocus: true,
    },
  };
}

Fifty lines. Testable in isolation. Reusable anywhere.

III. Primitive Two: EditOverlay

A visual wrapper that adds edit affordances to any component.

<EditOverlay
  isEditing={exportUnitEdit.isEditing}
  onEdit={exportUnitEdit.start}
  editor={<Input {...exportUnitEdit.inputProps} placeholder="e.g., TNE" />}
>
  <CheckItem label="Export Unit" detail={value} />
</EditOverlay>

The wrapper provides:

  1. Hover affordance: A pencil icon appears on hover.
  2. Content swap: When isEditing is true, the editor prop renders instead of children.
  3. Pure children: CheckItem receives no editing props—it stays a presentation component.

The Full Component

export function EditOverlay({
  isEditing,
  onEdit,
  editor,
  children,
  disabled,
}) {
  if (isEditing) return <>{editor}</>;

  return (
    <div className="group relative">
      {children}
      {!disabled && (
        <button
          onClick={onEdit}
          className="absolute right-1.5 top-1/2 -translate-y-1/2
                     opacity-0 group-hover:opacity-100 transition-opacity"
        >
          <Pencil className="h-3 w-3" />
        </button>
      )}
    </div>
  );
}

Thirty lines. The wrapper knows how to show a pencil and swap content. It knows nothing about what it wraps.

IV. Composition in Practice

Here is the Configuration Status grid with inline editing:

<div className="grid grid-cols-2 gap-3">
  <CheckItem passed={true} label="URA Goods" detail="204024..." />

  <EditOverlay
    isEditing={exportUnitEdit.isEditing}
    onEdit={exportUnitEdit.start}
    editor={<Input {...exportUnitEdit.inputProps} placeholder="e.g., TNE" />}
  >
    <CheckItem
      passed={hasExportUnit}
      label="Export Unit"
      detail={hasExportUnit ? editedValues.exportUnit : "Not set"}
    />
  </EditOverlay>

  <EditOverlay
    isEditing={scalingEdit.isEditing}
    onEdit={scalingEdit.start}
    editor={<Input {...scalingEdit.inputProps} type="number" />}
  >
    <CheckItem
      passed={hasScaling}
      label="Scaling"
      detail={hasScaling ? `×${editedValues.exportScalingFactor}` : "Missing"}
    />
  </EditOverlay>
</div>

Three patterns emerge:

  1. One hook per field: exportUnitEdit, scalingEdit, refPriceEdit.
  2. One wrapper per editable item: EditOverlay receives the hook’s state and callbacks.
  3. Presentation stays pure: CheckItem displays data. It doesn’t know it’s editable.

This is the Radix UI philosophy: logic and visuals are separate layers that compose at the call site.

V. What We Gained

CapabilityHow
Inline editing on any componentWrap it with EditOverlay
Keyboard shortcuts (Esc/Enter)Built into useInlineEdit.inputProps
Isolated re-rendersEach hook owns its state
Testable logicuseInlineEdit produces no JSX
Swappable triggersReplace EditOverlay with double-click, long-press, etc.

The two primitives total under 100 lines. They compose into any UI we need.

Coda: The Habit of Composition

When the next requirement arrives—inline editing on a different page, a different component—we will not build another EditableField. We will compose:

const edit = useInlineEdit({ initialValue, onSave });

<EditOverlay isEditing={edit.isEditing} onEdit={edit.start} editor={...}>
  <AnyComponent />
</EditOverlay>

Two primitives. Infinite combinations.

That is the habit Aristotle meant: not one excellent act, but a pattern of excellence you can repeat.


By the TaxBridge Engineering Team