Form library bridge
Drive grid edits from a form library - Felte, Superforms, Formsnap, or a plain
<form>.api.setCellValueis 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:
- Inline via the grid's built-in editors (commit on blur)
- Drawer / modal via a form library with its own validation
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:
- Felte: bind the form to
draft; the library handles validation. InonSubmit, callapi.setCellValuefor each changed key. - Superforms: pass
selectedas the initial form data; on submit, the validatedform.databecomes the source ofsetCellValuecalls. - Formsnap: a Superforms shape with built-in field components.
Same
onSubmitplumbing.
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:
- Event count (shown above) - simplest. Reset to 0 after a save.
- 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
- Side-drawer edit demo (97) - the slide-in pattern used as the form host
- Submit-time validation recipe - similar bulk-commit-then-validate pattern
- Undo / redo for grid edits - layer history on top of the same
setCellValuecalls SvGridApi.setCellValue
Related articles
- Bundle Size of Svelte Data Grids - How to Compare - README bundle numbers are nearly useless. Here is how to measure the real delta a data grid adds to your Svelte app, and why feature-gated architectures change the math entirely.
- Reducing Re-Renders in SvGrid with $derived - Svelte 5's $derived memoizes your row pipeline so the grid recomputes only when filter inputs actually change - not on every unrelated state update.
- Reactivity with Large Arrays and Objects in Svelte 5 - Deep proxies are powerful but not free. Here is when to pay the cost and when to use $state.raw to keep bulk data loads fast in a SvGrid app.