Skip to content

useInlineEdit

What this solves

Inline editing is deceptively repetitive:

  • You need a draft value and an original value
  • You need a clean transition between display and edit modes
  • You need consistent save/cancel behavior
  • You need to handle async saves and error states
  • You often need Enter/Escape keyboard behavior

Hand-rolled implementations tend to spread state across multiple components (display cell, editor, toolbar) and drift into inconsistent UX. useInlineEdit centralizes editing behavior with a small, reusable API.


When to use it

Use useInlineEdit when you need inline/optimistic editing behavior for:

  • Editable table cells
  • Editable titles/labels
  • Small form fields that toggle between read and edit modes

When NOT to use it

Do not use useInlineEdit when:

  • The edit flow is a full form or multi-step wizard (use a dedicated form state approach)
  • You need server-state caching/invalidation semantics (use React Query for server state)
  • You need complex validation flows or cross-field dependencies (keep that in a form layer)

Basic usage

useInlineEdit is headless: you decide what the display/editor UI looks like.

tsx
import { useInlineEdit } from '@/shared/hooks'

export function Example() {
  const edit = useInlineEdit<string>({
    initialValue: 'Hello',
    onSave: async (value) => {
      // await api.updateThing({ name: value })
    },
  })

  if (edit.isEditing) {
    return (
      <div>
        <input
          value={edit.draft}
          onChange={(e) => edit.setDraft(e.target.value)}
          {...edit.inputProps}
          autoFocus
        />
        <button type="button" onClick={edit.saveEdit} disabled={edit.isSaving}>Save</button>
        <button type="button" onClick={edit.cancelEdit} disabled={edit.isSaving}>Cancel</button>
        {edit.saveError && <p role="alert">Save failed.</p>}
      </div>
    )
  }

  return (
    <button type="button" onClick={edit.startEdit}>
      {edit.originalValue}
    </button>
  )
}

Common patterns

Pattern 1 — Hook for custom UX (maximum control)

Use the hook directly when you need custom behavior (syncing external updates, custom focus, custom layout).

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: onCommit,
  })

  // Keep external updates in sync when not editing
  React.useEffect(() => {
    if (!edit.isEditing) {
      edit.setOriginalValue(value)
    }
  }, [value, edit.isEditing, edit.setOriginalValue])

  return edit.isEditing ? (
    <input
      value={edit.draft}
      onChange={(e) => edit.setDraft(e.target.value)}
      {...edit.inputProps}
      autoFocus
    />
  ) : (
    <span onClick={edit.startEdit}>{edit.originalValue}</span>
  )
}

Why this is recommended:

  • Keeps edit state local and composable
  • Makes external value sync explicit (no hidden magic)

Pattern 2 — Use the InlineEditable wrapper when the default flow fits

Katalyst provides InlineEditable, a thin UI wrapper over useInlineEdit, when you want a standard display/editor toggle without wiring.

tsx
import { InlineEditable } from '@/shared/ui/components/InlineEditable'

export function Example({ name }: { name: string }) {
  return (
    <InlineEditable
      value={name}
      onSave={async (next) => {
        // await api.user.update({ name: next })
      }}
      renderDisplay={({ value, onStartEdit }) => (
        <button type="button" onClick={onStartEdit}>{value}</button>
      )}
      renderEditor={({ value, onChange, onSave, onCancel, isSaving, inputProps }) => (
        <div>
          <input value={value} onChange={(e) => onChange(e.target.value)} {...inputProps} autoFocus />
          <button type="button" onClick={onSave} disabled={isSaving}>Save</button>
          <button type="button" onClick={onCancel} disabled={isSaving}>Cancel</button>
        </div>
      )}
    />
  )
}

Things to avoid

  • Letting child components own the draft/original split (it becomes hard to keep consistent)
  • Mixing domain validation and persistence concerns into the editing primitive (keep that in onSave and the page layer)
  • Forgetting to sync external updates when not editing (use setOriginalValue when needed)


API reference (concise)

ts
export interface UseInlineEditOptions<T> {
  initialValue: T
  onSave?: (value: T) => Promise<void> | void
  onCancel?: () => void
  onSaveSuccess?: () => void
  onSaveError?: (error: Error) => void
  enableKeyboardShortcuts?: boolean
}

export interface UseInlineEditReturn<T> {
  isEditing: boolean
  draft: T
  originalValue: T
  isSaving: boolean
  saveError: Error | null
  startEdit: () => void
  cancelEdit: () => void
  saveEdit: () => Promise<void>
  setDraft: (value: T | ((prev: T) => T)) => void
  setOriginalValue: (value: T) => void
  inputProps: {
    onKeyDown: (e: React.KeyboardEvent) => void
  }
}

export function useInlineEdit<T>(options: UseInlineEditOptions<T>): UseInlineEditReturn<T>

Final notes

  • The default keyboard behavior is Enter to save and Escape to cancel (via inputProps and optional global shortcuts).
  • If you want to limit shortcuts to the input only, rely on inputProps rather than global document listeners.