Inline Edit Field
A TanStack Form field with click-to-edit, schema validation, and save-on-commit.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-inline-edit.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-inline-edit.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-inline-edit.json
'use client';import { InfoIcon } from 'lucide-react';import * as React from 'react';import { Field, FieldLabel } from '@/components/ui/field';import { Input } from '@/components/ui/input';import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';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'> {label?: string;description?: string;orientation?: 'vertical' | 'horizontal';}export function InlineEditField({ label, description, orientation = 'vertical', ...props }: InlineEditFieldProps) {const field = useFieldContext<string>();const { isValid } = field.state.meta;const handleCommit = async (next: string) => {field.handleChange(next);if (!field.state.meta.isValid) {const first = field.state.meta.errors[0];return typeof first === 'string' ? first : (first?.message ?? 'Invalid value');}await field.form.handleSubmit();return undefined;};return (<Fieldorientation={orientation}data-invalid={!isValid}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.state.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 TanStack 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.
When the value is valid the field triggers the form's submit, so your onSubmit handler
persists it (the field shows a saving indicator while it runs). The example below stores the
whole form in localStorage from onSubmit, so one handler covers every field.
Examples
Custom Editor
'use client';import { createFormHook } from '@tanstack/react-form';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});const statuses = ['Backlog', 'In progress', 'Done'];export default function TsfInlineEditCustomEditorExample() {const form = useAppForm({ defaultValues: { status: 'In progress' } });return (<div className='w-full max-w-sm'><form.AppFieldname='status'children={(field) => (<field.InlineEditField 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>)}</field.InlineEditField>)}/></div>);}
Default
'use client';import { createFormHook } from '@tanstack/react-form';import * as React from 'react';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});const STORAGE_KEY = 'shuip:tsf-inline-edit';const defaultValues = { title: 'Project Apollo', owner: 'Ada Lovelace' };function loadStored(): Partial<typeof defaultValues> {if (typeof window === 'undefined') return {};try {const raw = window.localStorage.getItem(STORAGE_KEY);return raw ? (JSON.parse(raw) as Partial<typeof defaultValues>) : {};} catch {return {};}}export default function TsfInlineEditExample() {const [stored, setStored] = React.useState(() => JSON.stringify(defaultValues));const form = useAppForm({defaultValues,onSubmit: ({ value }) => {const json = JSON.stringify(value);window.localStorage.setItem(STORAGE_KEY, json);setStored(json);},});React.useEffect(() => {const initial = { ...defaultValues, ...loadStored() };form.reset(initial);setStored(JSON.stringify(initial));}, [form]);return (<div className='flex w-full max-w-sm flex-col gap-4'><form.AppFieldname='title'validators={{ onChange: ({ value }) => (value.length < 3 ? 'Title must be at least 3 characters' : undefined) }}children={(field) => (<field.InlineEditField label='Title' description='Shown at the top of the project page.' />)}/><form.AppFieldname='owner'validators={{ onChange: ({ value }) => (value.length < 2 ? 'Owner is required' : undefined) }}children={(field) => <field.InlineEditField label='Owner' orientation='horizontal' />}/><p className='text-muted-foreground text-xs'>Persisted to localStorage: <code className='text-foreground'>{stored}</code></p></div>);}
Horizontal
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});export default function TsfInlineEditHorizontalExample() {const form = useAppForm({ defaultValues: { owner: 'Ada Lovelace', status: 'In progress' } });return (<div className='flex w-full max-w-md flex-col gap-3'><form.AppFieldname='owner'children={(field) => <field.InlineEditField label='Owner' orientation='horizontal' />}/><form.AppFieldname='status'children={(field) => <field.InlineEditField label='Status' orientation='horizontal' />}/></div>);}
No Label
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});export default function TsfInlineEditNoLabelExample() {const form = useAppForm({ defaultValues: { bio: '' } });return (<div className='w-full max-w-sm'><form.AppFieldname='bio'validators={{ onChange: ({ value }) => (value.length > 140 ? 'Keep it under 140 characters' : undefined) }}children={(field) => (<field.InlineEditField input='textarea' variant='boxed' placeholder='Add a description…' />)}/></div>);}
Profile
'use client';import { createFormHook } from '@tanstack/react-form';import * as React from 'react';import { z } from 'zod';import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';import { Separator } from '@/components/ui/separator';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});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:tsf-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 TsfInlineEditProfileExample() {const form = useAppForm({defaultValues,onSubmit: ({ value }) => {window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));},});React.useEffect(() => {form.reset({ ...defaultValues, ...loadStored() });}, [form]);return (<Card className='w-full max-w-md'><CardHeader className='flex-row items-center gap-4 space-y-0'><form.Subscribe selector={(state) => state.values.name}>{(name) => (<div className='bg-muted flex size-12 items-center justify-center rounded-full text-sm font-medium'>{initials(name)}</div>)}</form.Subscribe><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'><form.AppFieldname='name'validators={{ onChange: schema.shape.name }}children={(field) => <field.InlineEditField label='Name' orientation='horizontal' />}/><Separator /><form.AppFieldname='email'validators={{ onChange: schema.shape.email }}children={(field) => <field.InlineEditField label='Email' orientation='horizontal' />}/><Separator /><form.AppFieldname='role'validators={{ onChange: schema.shape.role }}children={(field) => (<field.InlineEditField label='Role' orientation='horizontal' description='Shown on your public profile.' />)}/><Separator /><form.AppFieldname='bio'validators={{ onChange: schema.shape.bio }}children={(field) => <field.InlineEditField label='Bio' input='textarea' placeholder='Add a short bio' />}/></CardContent></Card>);}
Sizes
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});export default function TsfInlineEditSizesExample() {const form = useAppForm({defaultValues: { heading: 'Untitled document', subtitle: 'A short subtitle', tag: 'draft' },});return (<div className='flex w-full max-w-sm flex-col gap-2'><form.AppField name='heading' children={(field) => <field.InlineEditField size='title' />} /><form.AppField name='subtitle' children={(field) => <field.InlineEditField />} /><form.AppField name='tag' children={(field) => <field.InlineEditField size='sm' />} /></div>);}
Variants
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InlineEditField } from '@/components/ui/shuip/tanstack-form/inline-edit';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InlineEditField },formComponents: {},});export default function TsfInlineEditVariantsExample() {const form = useAppForm({ defaultValues: { ghost: 'Seamless, no border', boxed: 'Bordered input on edit' } });return (<div className='flex w-full max-w-sm flex-col gap-4'><form.AppField name='ghost' children={(field) => <field.InlineEditField label='Ghost' variant='ghost' />} /><form.AppField name='boxed' children={(field) => <field.InlineEditField label='Boxed' variant='boxed' />} /></div>);}
Props
Prop
Type