Appearance
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
onSaveand the page layer) - Forgetting to sync external updates when not editing (use
setOriginalValuewhen needed)
Related primitives
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
inputPropsand optional global shortcuts). - If you want to limit shortcuts to the input only, rely on
inputPropsrather than global document listeners.