Inline Edit Field
A React Hook Form field with click-to-edit, schema validation, and save-on-commit.
npx shadcn@latest add https://shuip.plvo.dev/r/rhf-inline-edit.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-inline-edit.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-inline-edit.json
'use client';import type { Lens } from '@hookform/lenses';import { InfoIcon } from 'lucide-react';import * as React from 'react';import { useController, useFormContext } from 'react-hook-form';import { Field, FieldLabel } from '@/components/ui/field';import { Input } from '@/components/ui/input';import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';import { Textarea } from '@/components/ui/textarea';import { cn } from '@/lib/utils';type InlineEditSize = 'sm' | 'default' | 'title';type InlineEditVariant = 'ghost' | 'boxed';type InlineEditInput = 'text' | 'textarea';interface InlineEditProps {value: string;onCommit: (next: string) => Promise<string | undefined> | string | undefined;input?: InlineEditInput;variant?: InlineEditVariant;size?: InlineEditSize;placeholder?: string;canEdit?: boolean;children?: (api: {value: string;setValue: (v: string) => void;commit: (next?: string) => void;cancel: () => void;className: string;autoFocus: true;}) => React.ReactNode;}type State =| { kind: 'reading' }| { kind: 'editing'; draft: string; error?: string }| { kind: 'saving'; draft: string };const sizeClasses: Record<InlineEditSize, string> = {sm: 'px-1.5 py-0.5 text-xs',default: 'px-2 py-1 text-sm',title: 'px-2 py-1 text-3xl font-semibold tracking-tight',};const variantClasses: Record<InlineEditVariant, string> = {ghost:'border border-transparent bg-transparent hover:bg-muted focus-visible:bg-muted dark:bg-transparent dark:hover:bg-muted',boxed: 'border border-input bg-transparent shadow-xs focus-visible:border-ring',};function InlineEdit({value,onCommit,input = 'text',variant = 'ghost',size = 'default',placeholder = '—',canEdit = true,children,}: InlineEditProps) {const [state, setState] = React.useState<State>({ kind: 'reading' });const errorId = React.useId();const fieldClassName = cn('w-full rounded-md text-left outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',sizeClasses[size],variantClasses[variant],);const startEdit = () => {if (canEdit) setState({ kind: 'editing', draft: value });};const cancel = () => setState({ kind: 'reading' });const setDraft = (draft: string) => setState((s) => (s.kind === 'editing' ? { kind: 'editing', draft } : s));const commit = async (override?: string) => {if (state.kind !== 'editing') return;const draft = override ?? state.draft;if (draft === value) {setState({ kind: 'reading' });return;}setState({ kind: 'saving', draft });const error = await onCommit(draft);setState(error ? { kind: 'editing', draft, error } : { kind: 'reading' });};const displayedError = state.kind === 'editing' ? state.error : undefined;const isSaving = state.kind === 'saving';let content: React.ReactNode;if (state.kind === 'reading') {const isEmpty = value === '';content = (<spandata-slot='inline-edit'role={canEdit ? 'button' : undefined}tabIndex={canEdit ? 0 : undefined}onClick={canEdit ? startEdit : undefined}onKeyDown={canEdit? (e) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault();startEdit();}}: undefined}className={cn(fieldClassName,'block',canEdit ? 'cursor-text' : 'cursor-default',input === 'textarea' && 'whitespace-pre-wrap',isEmpty && 'text-muted-foreground',)}>{isEmpty ? placeholder : value}</span>);} else {const { draft } = state;const editorClassName = cn(fieldClassName,'h-auto min-w-0',variant === 'ghost' && 'shadow-none',input === 'textarea' && 'field-sizing-content min-h-0',);const handleKeyDown: React.KeyboardEventHandler = (e) => {if (e.key === 'Escape') {e.preventDefault();cancel();}if (e.key === 'Enter' && input === 'text') {e.preventDefault();void commit();}};content = children ? (children({value: draft,setValue: setDraft,commit: (next?: string) => void commit(next),cancel,className: editorClassName,autoFocus: true,})) : input === 'textarea' ? (<TextareaautoFocusvalue={draft}disabled={isSaving}aria-invalid={!!displayedError}aria-describedby={displayedError ? errorId : undefined}onChange={(e) => setDraft(e.target.value)}onBlur={() => void commit()}onKeyDown={handleKeyDown}className={editorClassName}/>) : (<InputautoFocustype='text'value={draft}disabled={isSaving}aria-invalid={!!displayedError}aria-describedby={displayedError ? errorId : undefined}onChange={(e) => setDraft(e.target.value)}onBlur={() => void commit()}onKeyDown={handleKeyDown}className={editorClassName}/>);}return (<div className='flex w-full flex-col gap-1'><div className='flex items-center gap-2'><div className='min-w-0 flex-1'>{content}</div>{isSaving && <span aria-hidden className='size-1.5 shrink-0 animate-pulse rounded-full bg-primary/60' />}</div>{displayedError && (<p id={errorId} className='text-xs text-destructive'>{displayedError}</p>)}</div>);}export interface InlineEditFieldPropsextends Pick<InlineEditProps, 'input' | 'variant' | 'size' | 'placeholder' | 'canEdit' | 'children'> {lens: Lens<string>;label?: string;description?: string;orientation?: 'vertical' | 'horizontal';}export function InlineEditField({lens,label,description,orientation = 'vertical',...props}: InlineEditFieldProps) {const { field, fieldState } = useController(lens.interop());const { trigger, getFieldState, formState } = useFormContext();const handleCommit = async (next: string) => {field.onChange(next);if (await trigger(field.name)) return undefined;return getFieldState(field.name, formState).error?.message ?? 'Invalid value';};return (<Fieldorientation={orientation}data-invalid={fieldState.invalid}className={cn('gap-2', orientation === 'horizontal' && '[&>[data-slot=field-label]]:flex-none')}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<div className={cn('flex items-center gap-2', orientation === 'horizontal' && 'flex-1')}><InlineEdit {...props} value={field.value ?? ''} onCommit={handleCommit} />{description && (<Popover><PopoverTrigger aria-label='Info' className='text-muted-foreground transition-colors hover:text-foreground'><InfoIcon className='size-4' /></PopoverTrigger><PopoverContent className='w-64 text-sm'>{description}</PopoverContent></Popover>)}</div></Field>);}
A click-to-edit field for React Hook Form. The value displays as text and turns into an editable input on click; read and edit share one typography scale, so editing is seamless. On each commit the field writes its value to the form and validates it — an invalid value keeps the field in edit mode with the error shown inline.
Persisting is the form's job, not the field's: subscribe to the form with form.watch (or
submit it). The example below autosaves the whole form to localStorage from a single
watch subscription, so one handler covers every field.
Examples
Custom Editor
'use client';import { useLens } from '@hookform/lenses';import { useForm } from 'react-hook-form';import { Form } from '@/components/ui/form';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';const statuses = ['Backlog', 'In progress', 'Done'];export default function RhfInlineEditCustomEditorExample() {const form = useForm({ defaultValues: { status: 'In progress' }, mode: 'onChange' });const lens = useLens({ control: form.control });return (<Form {...form}><div className='w-full max-w-sm'><InlineEditField lens={lens.focus('status')} label='Status'>{({ value, commit, cancel }) => (<SelectdefaultOpenvalue={value}onValueChange={commit}onOpenChange={(open) => {if (!open) cancel();}}><SelectTrigger className='w-full'><SelectValue /></SelectTrigger><SelectContent>{statuses.map((status) => (<SelectItem key={status} value={status}>{status}</SelectItem>))}</SelectContent></Select>)}</InlineEditField></div></Form>);}
Default
'use client';import { useLens } from '@hookform/lenses';import { zodResolver } from '@hookform/resolvers/zod';import * as React from 'react';import { useForm } from 'react-hook-form';import { z } from 'zod';import { Form } from '@/components/ui/form';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';const schema = z.object({title: z.string().min(3, { message: 'Title must be at least 3 characters' }),owner: z.string().min(2, { message: 'Owner is required' }),});type Values = z.infer<typeof schema>;const STORAGE_KEY = 'shuip:rhf-inline-edit';const defaultValues: Values = { title: 'Project Apollo', owner: 'Ada Lovelace' };function loadStored(): Partial<Values> {if (typeof window === 'undefined') return {};try {const raw = window.localStorage.getItem(STORAGE_KEY);return raw ? (JSON.parse(raw) as Partial<Values>) : {};} catch {return {};}}export default function RhfInlineEditExample() {const form = useForm<Values>({ defaultValues, resolver: zodResolver(schema), mode: 'onChange' });const lens = useLens({ control: form.control });const [stored, setStored] = React.useState(() => JSON.stringify(defaultValues));React.useEffect(() => {form.reset({ ...defaultValues, ...loadStored() });const subscription = form.watch((values) => {const parsed = schema.safeParse(values);if (!parsed.success) return;const json = JSON.stringify(parsed.data);window.localStorage.setItem(STORAGE_KEY, json);setStored(json);});return () => subscription.unsubscribe();}, [form]);return (<Form {...form}><div className='flex w-full max-w-sm flex-col gap-4'><InlineEditField lens={lens.focus('title')} label='Title' description='Shown at the top of the project page.' /><InlineEditField lens={lens.focus('owner')} label='Owner' orientation='horizontal' /><p className='text-muted-foreground text-xs'>Persisted to localStorage: <code className='text-foreground'>{stored}</code></p></div></Form>);}
Horizontal
'use client';import { useLens } from '@hookform/lenses';import { useForm } from 'react-hook-form';import { Form } from '@/components/ui/form';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';export default function RhfInlineEditHorizontalExample() {const form = useForm({defaultValues: { owner: 'Ada Lovelace', status: 'In progress' },mode: 'onChange',});const lens = useLens({ control: form.control });return (<Form {...form}><div className='flex w-full max-w-md flex-col gap-3'><InlineEditField lens={lens.focus('owner')} label='Owner' orientation='horizontal' /><InlineEditField lens={lens.focus('status')} label='Status' orientation='horizontal' /></div></Form>);}
No Label
'use client';import { useLens } from '@hookform/lenses';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { z } from 'zod';import { Form } from '@/components/ui/form';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';const schema = z.object({bio: z.string().max(140, { message: 'Keep it under 140 characters' }),});type Values = z.infer<typeof schema>;export default function RhfInlineEditNoLabelExample() {const form = useForm<Values>({ defaultValues: { bio: '' }, resolver: zodResolver(schema), mode: 'onChange' });const lens = useLens({ control: form.control });return (<Form {...form}><div className='w-full max-w-sm'><InlineEditField lens={lens.focus('bio')} input='textarea' variant='boxed' placeholder='Add a description…' /></div></Form>);}
Profile
'use client';import { useLens } from '@hookform/lenses';import { zodResolver } from '@hookform/resolvers/zod';import * as React from 'react';import { useForm } from 'react-hook-form';import { z } from 'zod';import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';import { Form } from '@/components/ui/form';import { Separator } from '@/components/ui/separator';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';const schema = z.object({name: z.string().min(2, { message: 'Name is required' }),email: z.string().email({ message: 'Enter a valid email' }),role: z.string().min(2, { message: 'Role is required' }),bio: z.string().max(160, { message: 'Keep it under 160 characters' }),});type Values = z.infer<typeof schema>;const STORAGE_KEY = 'shuip:rhf-inline-edit:profile';const defaultValues: Values = {name: 'Ada Lovelace',email: 'ada.lovelace@example.com',role: 'Lead Mathematician',bio: 'First to publish an algorithm intended for a machine.',};function loadStored(): Partial<Values> {if (typeof window === 'undefined') return {};try {const raw = window.localStorage.getItem(STORAGE_KEY);return raw ? (JSON.parse(raw) as Partial<Values>) : {};} catch {return {};}}function initials(name: string) {return (name.split(' ').map((part) => part[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '?');}export default function RhfInlineEditProfileExample() {const form = useForm<Values>({ defaultValues, resolver: zodResolver(schema), mode: 'onChange' });const lens = useLens({ control: form.control });const name = form.watch('name');React.useEffect(() => {form.reset({ ...defaultValues, ...loadStored() });const subscription = form.watch((values) => {const parsed = schema.safeParse(values);if (parsed.success) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed.data));});return () => subscription.unsubscribe();}, [form]);return (<Form {...form}><Card className='w-full max-w-md'><CardHeader className='flex-row items-center gap-4 space-y-0'><div className='bg-muted flex size-12 items-center justify-center rounded-full text-sm font-medium'>{initials(name)}</div><div className='space-y-1'><CardTitle>Account</CardTitle><CardDescription>Click any value to edit — changes save as you go.</CardDescription></div></CardHeader><CardContent className='flex flex-col gap-3'><InlineEditField lens={lens.focus('name')} label='Name' orientation='horizontal' /><Separator /><InlineEditField lens={lens.focus('email')} label='Email' orientation='horizontal' /><Separator /><InlineEditFieldlens={lens.focus('role')}label='Role'orientation='horizontal'description='Shown on your public profile.'/><Separator /><InlineEditField lens={lens.focus('bio')} label='Bio' input='textarea' placeholder='Add a short bio' /></CardContent></Card></Form>);}
Sizes
'use client';import { useLens } from '@hookform/lenses';import { useForm } from 'react-hook-form';import { Form } from '@/components/ui/form';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';export default function RhfInlineEditSizesExample() {const form = useForm({defaultValues: { heading: 'Untitled document', subtitle: 'A short subtitle', tag: 'draft' },mode: 'onChange',});const lens = useLens({ control: form.control });return (<Form {...form}><div className='flex w-full max-w-sm flex-col gap-2'><InlineEditField lens={lens.focus('heading')} size='title' /><InlineEditField lens={lens.focus('subtitle')} /><InlineEditField lens={lens.focus('tag')} size='sm' /></div></Form>);}
Variants
'use client';import { useLens } from '@hookform/lenses';import { useForm } from 'react-hook-form';import { Form } from '@/components/ui/form';import { InlineEditField } from '@/components/ui/shuip/react-hook-form/inline-edit';export default function RhfInlineEditVariantsExample() {const form = useForm({defaultValues: { ghost: 'Seamless, no border', boxed: 'Bordered input on edit' },mode: 'onChange',});const lens = useLens({ control: form.control });return (<Form {...form}><div className='flex w-full max-w-sm flex-col gap-4'><InlineEditField lens={lens.focus('ghost')} label='Ghost' variant='ghost' /><InlineEditField lens={lens.focus('boxed')} label='Boxed' variant='boxed' /></div></Form>);}
Props
Prop
Type