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 = (
<span
data-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' ? (
<Textarea
autoFocus
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}
/>
) : (
<Input
autoFocus
type='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 InlineEditFieldProps
extends 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 (
<Field
orientation={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>
);
}
Loading...

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

Loading...
'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 }) => (
<Select
defaultOpen
value={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

Loading...
'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

Loading...
'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

Loading...
'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

Loading...
'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 />
<InlineEditField
lens={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

Loading...
'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

Loading...
'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

On this page