Appearance
Inline Editing
Problem
Inline editing needs consistent behavior across the app:
- A read mode and an edit mode
- Draft vs original value
- Save/cancel semantics
- Async save state (disable controls, show errors)
- Keyboard behavior (Enter to save, Escape to cancel)
When implemented ad hoc, teams usually end up with:
- Inconsistent keyboard behavior per screen
- Lost edits due to stale original values
- Save errors that don’t surface clearly
Solution
Use useInlineEdit as the single controller for inline edit state.
- The hook owns
draft,originalValue, andisEditing - Async save state is exposed via
isSaving/saveError - Keyboard handling is provided through
inputProps.onKeyDown
This pattern stays headless: the page/component decides how to render display vs editor.
Implementation
Inline rename (single field)
tsx
import React from 'react'
import { useInlineEdit } from '@/shared/hooks'
type Props = {
value: string
onCommit: (next: string) => Promise<void>
}
export function InlineName({ value, onCommit }: Props) {
const edit = useInlineEdit<string>({
initialValue: value,
onSave: async (next) => {
await onCommit(next)
},
enableKeyboardShortcuts: false,
})
React.useEffect(() => {
if (!edit.isEditing) {
edit.setOriginalValue(value)
}
}, [value, edit.isEditing, edit.setOriginalValue])
if (!edit.isEditing) {
return (
<button type="button" onClick={edit.startEdit}>
{edit.originalValue}
</button>
)
}
return (
<div>
<input
value={edit.draft}
onChange={(e) => edit.setDraft(e.target.value)}
onKeyDown={edit.inputProps.onKeyDown}
autoFocus
/>
<button type="button" onClick={edit.saveEdit} disabled={edit.isSaving}>
{edit.isSaving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={edit.cancelEdit} disabled={edit.isSaving}>
Cancel
</button>
{edit.saveError && <p role="alert">Save failed.</p>}
</div>
)
}Variations
- Save-on-blur
- Use
onBlur={() => edit.saveEdit()}if blur-save is desired. - Ensure cancel is still available (Escape) and that blur doesn’t fire when clicking Cancel.
- Use
- Editable objects (card edit mode)
- Use
useInlineEdit<{...}>with a draft object. - Update fields via
setDraft((prev) => ({ ...prev, field })).
- Use
- Component wrapper
- Use
InlineEditablewhen the standard flow fits and you want to supply render functions.
- Use
Accessibility considerations
- In display mode, ensure the trigger is keyboard accessible (
buttonorrole="button"+tabIndex={0}). - In edit mode, focus the input on entry, and preserve focus on save errors.
- Announce save errors via
role="alert".