Form library bridge

Drive grid edits from a form library - Felte, Superforms, Formsnap, or a plain <form>. api.setCellValue is the single write surface; any UI that funnels through it stays consistent with inline editing.

When

You want the same row to be editable two ways:

Both writers must update the SAME underlying data so users can edit either way and not lose work. Common in admin tools, CRMs, and audit queues where some fields are best inline (status pill) and others need a real form (long-text + validation + dependent dropdowns).

The pattern

The examples on this page run against these rows:

<script lang="ts">
  import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'

  type Person = {
    id: number
    name: string
    department: string
    city: string
    age: number
    salary: number
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000 },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000 },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000 },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000 },
  ]

  let rows = $state<Person[]>(people)

  const columns: GridColumns<Person> = [
    { field: 'name',       header: 'Name',       width: 190, editorType: 'text' },
    { field: 'department', header: 'Department', width: 150, editorType: 'text' },
    { field: 'city',       header: 'City',       width: 130, editorType: 'text' },
    { field: 'age',        header: 'Age',        width: 80,  editorType: 'number' },
    { field: 'salary',     header: 'Salary',     width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },
  ]
</script>
┌──────────────────┐         setCellValue(idx, field, v)         ┌──────────┐
│  Inline editor   │ ──────────────────────────────────────────► │  data[]  │
└──────────────────┘                                             │  $state  │
                                                                 │          │
┌──────────────────┐         setCellValue(idx, field, v)         │          │
│  Drawer <form>   │ ──────────────────────────────────────────► │          │
└──────────────────┘                                             └──────────┘

Same write path. Same dirty tracking (count onCellValueChange events). Whatever lives in data[] is the truth.

Wire-up

<script lang="ts">
  import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi } from '@svgrid/grid'

  type Employee = { id: string; name: string; email: string; team: 'Eng' | 'Sales' | 'Ops'; salary: number; remote: boolean }
  let employees = $state<Employee[]>([/* ... */])

  const features = tableFeatures({ rowSortingFeature })
  const columns = [
    { field: 'id',     header: 'ID',     editable: false },
    { field: 'name',   header: 'Name',   editorType: 'text' },
    { field: 'email',  header: 'Email',  editorType: 'text' },
    { field: 'team',   header: 'Team',   editorType: 'select',
      editorOptions: ['Eng', 'Sales', 'Ops'].map((v) => ({ value: v })) },
    { field: 'salary', header: 'Salary', editorType: 'number',
      format: { type: 'currency', currency: 'USD' } },
    { field: 'remote', header: 'Remote', editorType: 'checkbox' },
  ] as const

  let api = $state<SvGridApi<typeof features, Employee> | null>(null)
  let selectedId = $state<string>('E001')
  const selected = $derived(employees.find((e) => e.id === selectedId) ?? null)

  // Draft state for the drawer form
  type FormDraft = Omit<Employee, 'id'>
  let draft = $state<FormDraft | null>(null)
  let errors = $state<Partial<Record<keyof FormDraft, string>>>({})

  $effect(() => {
    if (selected) draft = { ...selected }
    else          draft = null
    errors = {}
  })

  function validate(d: FormDraft): Partial<Record<keyof FormDraft, string>> {
    const out: Partial<Record<keyof FormDraft, string>> = {}
    if (d.name.trim().length < 2) out.name = 'Name must be at least 2 characters'
    if (!/^\S+@\S+\.\S+$/.test(d.email)) out.email = 'Looks unlike an email'
    if (d.salary < 0 || d.salary > 10_000_000) out.salary = 'Salary out of range'
    return out
  }

  function isDirty(): boolean {
    if (!selected || !draft) return false
    return (Object.keys(draft) as Array<keyof FormDraft>)
      .some((k) => draft![k] !== selected[k])
  }

  function submit() {
    if (!api || !selected || !draft) return
    const e = validate(draft)
    errors = e
    if (Object.keys(e).length > 0) return
    // setCellValue takes a rowIndex (number), not an id.
    // In a real app, memoise this lookup.
    const rowIndex = employees.findIndex((emp) => emp.id === selected.id)
    if (rowIndex < 0) return
    for (const k of Object.keys(draft) as Array<keyof FormDraft>) {
      if (draft[k] !== selected[k]) {
        api.setCellValue(rowIndex, k as string, draft[k] as unknown)
      }
    }
  }

  // Dirty-state count: increment on every cell change from EITHER UI.
  let dirtyCount = $state(0)
  function onCellChanged() { dirtyCount += 1 }
</script>

<SvGrid
  data={employees}
  {columns}
  {features}
  enableInlineEditing
  getRowId={(r) => r.id}
  onApiReady={(a) => (api = a)}
  onCellValueChange={onCellChanged}
/>

{#if draft}
  <form onsubmit={(e) => { e.preventDefault(); submit() }}>
    <label>Name <input type="text" bind:value={draft.name} /></label>
    {#if errors.name}<em>{errors.name}</em>{/if}
    <label>Email <input type="email" bind:value={draft.email} /></label>
    {#if errors.email}<em>{errors.email}</em>{/if}
    <label>Salary <input type="number" bind:value={draft.salary} /></label>
    <button type="submit" disabled={!isDirty()}>Save → setCellValue</button>
  </form>
{/if}

Plugging Felte / Superforms

Same pattern with the library's idiomatic surface:

The library doesn't care which form library renders the inputs - the contract is just "after validation, call api.setCellValue per dirty field".

Tracking "dirty" across both UIs

Two valid strategies:

  1. Event count (shown above) - simplest. Reset to 0 after a save.
  2. Snapshot diff - keep an initial copy of the data array; JSON.stringify(initial) !== JSON.stringify(api.getData()) is dirty. More accurate, more expensive.

Most apps want #1.

Try it

api.setCellValue is the single write surface. A plain form funnelling through it stays consistent with inline editing - edit either way and both agree.

<script lang="ts">
  let api = $state<SvGridApi<{}, Person> | null>(null)
  let city = $state('Berlin')

  // Row 0, the city column. Going through the api rather than mutating the
  // array means the grid's dirty tracking and observers see the write.
  function apply(e: Event) {
    e.preventDefault()
    api?.setCellValue(0, 'city', city)
  }
</script>

<form onsubmit={apply}>
  <label>
    City for row 1
    <input bind:value={city} />
  </label>
  <button type="submit">Apply</button>
</form>

<SvGrid data={rows} {columns} editable onApiReady={(next) => (api = next)} />

See also

Related articles