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:
- Composable: Works with any presentation component (
CheckItem,TaxItem,Field). - Keyboard-friendly: Escape cancels, Enter saves.
- 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:
| Property | Purpose |
|---|---|
isEditing | Boolean: are we in edit mode? |
draft | The current working value |
start() | Enter edit mode, populate draft |
cancel() | Exit edit mode, discard changes |
save() | Exit edit mode, call onSave with draft |
inputProps | Spread 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:
- Hover affordance: A pencil icon appears on hover.
- Content swap: When
isEditingis true, theeditorprop renders instead of children. - Pure children:
CheckItemreceives 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:
- One hook per field:
exportUnitEdit,scalingEdit,refPriceEdit. - One wrapper per editable item:
EditOverlayreceives the hook’s state and callbacks. - Presentation stays pure:
CheckItemdisplays 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
| Capability | How |
|---|---|
| Inline editing on any component | Wrap it with EditOverlay |
| Keyboard shortcuts (Esc/Enter) | Built into useInlineEdit.inputProps |
| Isolated re-renders | Each hook owns its state |
| Testable logic | useInlineEdit produces no JSX |
| Swappable triggers | Replace 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