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 = (
<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'> {
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 (
<Field
orientation={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>
);
}
Loading...

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

Loading...
'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.AppField
name='status'
children={(field) => (
<field.InlineEditField 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>
)}
</field.InlineEditField>
)}
/>
</div>
);
}

Default

Loading...
'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.AppField
name='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.AppField
name='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

Loading...
'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.AppField
name='owner'
children={(field) => <field.InlineEditField label='Owner' orientation='horizontal' />}
/>
<form.AppField
name='status'
children={(field) => <field.InlineEditField label='Status' orientation='horizontal' />}
/>
</div>
);
}

No Label

Loading...
'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.AppField
name='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

Loading...
'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.AppField
name='name'
validators={{ onChange: schema.shape.name }}
children={(field) => <field.InlineEditField label='Name' orientation='horizontal' />}
/>
<Separator />
<form.AppField
name='email'
validators={{ onChange: schema.shape.email }}
children={(field) => <field.InlineEditField label='Email' orientation='horizontal' />}
/>
<Separator />
<form.AppField
name='role'
validators={{ onChange: schema.shape.role }}
children={(field) => (
<field.InlineEditField label='Role' orientation='horizontal' description='Shown on your public profile.' />
)}
/>
<Separator />
<form.AppField
name='bio'
validators={{ onChange: schema.shape.bio }}
children={(field) => <field.InlineEditField label='Bio' input='textarea' placeholder='Add a short bio' />}
/>
</CardContent>
</Card>
);
}

Sizes

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

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

On this page