Skip to content

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, and isEditing
  • 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.
  • Editable objects (card edit mode)
    • Use useInlineEdit<{...}> with a draft object.
    • Update fields via setDraft((prev) => ({ ...prev, field })).
  • Component wrapper
    • Use InlineEditable when the standard flow fits and you want to supply render functions.

Accessibility considerations

  • In display mode, ensure the trigger is keyboard accessible (button or role="button" + tabIndex={0}).
  • In edit mode, focus the input on entry, and preserve focus on save errors.
  • Announce save errors via role="alert".