Inline Edit Field

A React Hook Form field with click-to-edit, schema validation, and save-on-commit.

npx shadcn@latest add @shuip/rhf-inline-edit
pnpm dlx shadcn@latest add @shuip/rhf-inline-edit
bun x shadcn@latest add @shuip/rhf-inline-edit
'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>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

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.

Unlike the other React Hook Form fields, InlineEditField must render under <FormProvider {...form}>: it validates on commit through useFormContext().trigger, and throws without the provider.

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 { FormProvider, useForm } from 'react-hook-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 (
    <FormProvider {...form}>
      <div className='w-full max-w-sm'>
        <InlineEditField lens={lens.focus('status')} label='Status'>
          {({ value, commit, cancel }) => (
            <Select
              defaultOpen
              value={value}
              onValueChange={(next: string | null) => next !== null && commit(next)}
              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>
    </FormProvider>
  );
}

Default

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import * as React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod';
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 (
    <FormProvider {...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>
    </FormProvider>
  );
}

Horizontal

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { FormProvider, useForm } from 'react-hook-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 (
    <FormProvider {...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>
    </FormProvider>
  );
}

No Label

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod';
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 (
    <FormProvider {...form}>
      <div className='w-full max-w-sm'>
        <InlineEditField lens={lens.focus('bio')} input='textarea' variant='boxed' placeholder='Add a description…' />
      </div>
    </FormProvider>
  );
}

Profile

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import * as React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 (
    <FormProvider {...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>
    </FormProvider>
  );
}

Sizes

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { FormProvider, useForm } from 'react-hook-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 (
    <FormProvider {...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>
    </FormProvider>
  );
}

Variants

Loading...
'use client';

import { useLens } from '@hookform/lenses';
import { FormProvider, useForm } from 'react-hook-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 (
    <FormProvider {...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>
    </FormProvider>
  );
}

Props

Prop

Type

On this page