Migrate existing forms
A CRUD dialog often starts with a model and a few rules. Add edit loading, reset, dependent options, dynamic rows, drafts, and several sections, and the page accumulates refs, watchers, loading flags, dirty checks, errors, and submit locks. Each page ends up maintaining a similar form state machine.
vformjs keeps the Form from Element Plus, element-ui, Naive UI, or Ant Design Vue and moves mode, baseline, validation, submission, and error state into one typed form instance. Existing templates and business components stay in place, and the same API continues to work as the page grows.
A dialog with create, edit, reset, and submit is enough to test the result: an edited record no longer leaks into the next create flow, submit state disappears from page code, and the host Form keeps its native validation feedback.
| Page shape | One form instance owns | Business code keeps |
|---|---|---|
| Regular CRUD | Defaults, mode, validation, reset, submit state | API and post-success page actions |
| Dynamic form | Active conditions and rules, stable row keys, error remap, option requests | Domain predicates and data endpoints |
| Large form | Explicit tracking, array operations, drafts, API field errors | Layout, calculations, uploads, payload transforms |
| Multi-section form | Aggregate validation, mode, errors, submit, reset | Section boundaries and final payload |
1. Regular CRUD: one form owns create, edit, and reset
Five pieces of state scattered across the page
Pages commonly maintain the host ref, defaults, rules, reset order, and submit lock separately. After editing one record, its identifier and validation state can leak into the next create flow.
One form owns the lifecycle
<script setup lang="ts">
import { reactive, shallowRef, useTemplateRef } from 'vue'
import { shallowRef } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { r, useElForm } from '@vformjs/element-plus'
interface ProfileForm {
recordId: string | undefined
name: string
email: string
}
const visible = shallowRef(false)
const submitting = shallowRef(false)
const formRef = useTemplateRef<FormInstance>('form')
const model = reactive<ProfileForm>({ recordId: undefined, name: '', email: '' })
const rules: FormRules<ProfileForm> = {
name: [{ required: true, message: 'Name is required' }],
email: [{ type: 'email', message: 'Enter a valid email' }],
}
const form = useElForm<ProfileForm>({
defaults: { recordId: undefined, name: '', email: '' },
rules: {
name: [r.required()],
email: [r.email()],
},
async onSubmit(values) {
await recordApi.save(values)
visible.value = false
},
})
function openCreate() {
Object.assign(model, { recordId: undefined, name: '', email: '' })
formRef.value?.clearValidate()
form.load('create')
visible.value = true
}
function openEdit(detail: ProfileForm) {
Object.assign(model, detail)
formRef.value?.clearValidate()
form.load('edit', detail)
visible.value = true
}
async function submit() {
await formRef.value?.validate()
submitting.value = true
try {
await recordApi.save(model)
visible.value = false
}
finally {
submitting.value = false
}
await form.submit()
}
</script>
<template>
<el-form ref="form" :model="model" :rules="rules">
<el-form v-bind="form.host">
<el-form-item label="Name" prop="name">
<el-input v-model="model.name" />
<el-input v-model="form.model.name" />
</el-form-item>
<el-button :loading="submitting" @click="submit">Save</el-button>
<el-button :loading="form.submitting" @click="submit">Save</el-button>
</el-form>
</template>Page actions stay explicit
- Keep
recordId: undefinedindefaults; otherwise create mode can retain the previous edit identifier. - Keep the host-native
propat level 1. Switch toform.item(path)only when core/API field errors must be rendered by that FormItem. - The API call still exists. It moves into
onSubmit, while success messages, dialog closing, and list refreshes stay explicit. - Search-only forms have no mode or submit lifecycle. The host-native Form already covers the required state.
2. Dynamic forms: define conditions, rows, and remote options together
Dependencies split across the page
Visibility often lives in the template, dependent requests and loading flags in watchers, and dynamic rows in another set of temporary keys, index rules, and error cleanup. The field dependency graph is scattered across the page.
Adopt vformjs
import { r, useElForm } from '@vformjs/element-plus'
interface ContactRow {
name: string
phone: string
phoneRequired: boolean
}
const model = reactive({ category: '', region: '', contacts: [] as ContactRow[] })
const regionOptions = shallowRef<Array<{ label: string, value: string }>>([])
const regionLoading = shallowRef(false)
watch(() => model.category, async (category) => {
model.region = ''
regionLoading.value = true
try {
regionOptions.value = await catalogApi.regions(category)
}
finally {
regionLoading.value = false
}
})
function appendContact() {
model.contacts.push({ name: '', phone: '', phoneRequired: false })
}
const form = useElForm({
defaults: { category: '', region: '', contacts: [] as ContactRow[] },
rules: {
'contacts.*.name': r.required(),
'contacts.*.phone': ({ item }) =>
(item as ContactRow).phoneRequired ? r.required() : null,
},
when: {
region: values => values.category === 'regional',
},
options: {
region: {
deps: ['category'],
load: ({ get, signal }) =>
catalogApi.regions(String(get('category')), { signal }),
},
},
})
const contacts = form.list<ContactRow>('contacts', {
defaultItem: () => ({ name: '', phone: '', phoneRequired: false }),
})
const hideRegion = form.hidden('region')
const availableRegions = form.options('region') <template>
<el-form v-bind="form.host">
<el-form-item v-if="model.category === 'regional'" label="Region" prop="region">
<el-form-item v-if="!hideRegion" label="Region" v-bind="form.item('region')">
<el-select v-model="model.region" :loading="regionLoading">
<el-option v-for="option in regionOptions" :key="option.value" v-bind="option" />
<el-select v-model="form.model.region" :loading="availableRegions.loading">
<el-option v-for="option in availableRegions.items" :key="option.value" v-bind="option" />
</el-select>
</el-form-item>
<div v-for="(row, index) in model.contacts" :key="index">
<div v-for="row in contacts.fields" :key="row.key">
<el-form-item :prop="`contacts.${index}.name`">
<el-form-item v-bind="form.item(`contacts.${row.index}.name`)">
<el-input v-model="form.model.contacts[row.index].name" />
</el-form-item>
<el-button @click="model.contacts.splice(index, 1)">Remove</el-button>
<el-button @click="contacts.remove(row.index)">Remove</el-button>
</div>
<el-button @click="appendContact">Add contact</el-button>
<el-button @click="contacts.append()">Add contact</el-button>
</el-form>
</template>Requests, row keys, and rules each have an owner
optionsresets the dependent value, aborts superseded requests, and keeps only the latest result. Business code still supplies the domain endpoint.- Keys from
contacts.fieldsnever enter submitted values. Existing field errors follow the matching business row through move and remove operations. whencontrols visibility; conditional callbacks inrulescontrol active validation. Hidden fields leave validation with their rules removed.
Track fields explicitly
import type { FormInstance } from 'element-plus'
import { r, submitFail, useElForm } from '@vformjs/element-plus'
interface LineRow {
itemCode: string
quantity: number
}
interface DocumentForm {
documentId: string | undefined
title: string
notes: string
lines: LineRow[]
attachmentIds: string[]
}
function createDefaults(): DocumentForm {
return { documentId: undefined, title: '', notes: '', lines: [], attachmentIds: [] }
}
const hostRef = useTemplateRef<FormInstance>('host')
const model = reactive(createDefaults())
const baseline = shallowRef(structuredClone(model))
const submitting = shallowRef(false)
const changedPaths = shallowRef<string[]>([])
watch(model, () => {
changedPaths.value = diffDocument(baseline.value, model)
}, { deep: true })
const form = useElForm<DocumentForm>({
defaults: createDefaults,
tracking: 'explicit',
rules: {
title: [r.required()],
'lines.*.itemCode': [r.required()],
'lines.*.quantity': [r.numberMin(1)],
},
async onSubmit(values) {
const response = await documentApi.save(values)
if (!response.ok) {
return submitFail(response.error, { errors: response.fieldErrors })
}
},
})
const title = form.field('title')
const lines = form.list<LineRow>('lines', {
defaultItem: () => ({ itemCode: '', quantity: 1 }),
})
async function submit() {
await hostRef.value?.validate()
submitting.value = true
try {
await documentApi.save(model)
}
catch (error) {
projectServerErrors(error, hostRef.value)
}
finally {
submitting.value = false
}
await form.submit()
}
function saveDraft() {
draftStore.save(structuredClone(model))
draftStore.save(form.snapshotDraft())
}
function restoreDraft(snapshot: unknown) {
Object.assign(model, snapshot)
form.restoreDraft(snapshot)
}<template>
<el-form ref="host" :model="model">
<el-form v-bind="form.host">
<el-input v-model="model.title" />
<el-input v-model="title" />
<el-table :data="model.lines">
<el-table :data="form.model.lines">
<!-- Domain columns, calculations, and upload controls stay unchanged. -->
</el-table>
<el-button @click="model.lines.push({ itemCode: '', quantity: 1 })">Add row</el-button>
<el-button @click="lines.append()">Add row</el-button>
</el-form>
</template>Drafts, errors, and business logic each have an owner
tracking: 'explicit'routes updates throughform.field(path),form.set, or field-array methods and avoids cloning and diffing the full model on every input.form.snapshotDraft()creates a versioned snapshot.form.restoreDraft()drops obsolete paths, fills new paths, and keeps the restored draft dirty instead of silently rebasing it.- Upload transport, table columns, domain calculations, and payload transforms stay in business code.
4. Multi-section forms: compose forms and preserve section boundaries
The parent orchestrates every section
A parent page calls several component refs, validates them concurrently, joins models, propagates loading, and resets each host. Adding or removing one section also changes the parent submit and reset flows.
Adopt vformjs
import { useElForm, useFormGroup } from '@vformjs/element-plus'
import { reactive, useTemplateRef } from 'vue'
import type { FormInstance } from 'element-plus'
const baseHost = useTemplateRef<FormInstance>('baseHost')
const linesHost = useTemplateRef<FormInstance>('linesHost')
const reviewHost = useTemplateRef<FormInstance>('reviewHost')
const baseModel = reactive({ title: '' })
const baseForm = useElForm({ defaults: { title: '' } })
const linesForm = useElForm({ defaults: { lines: [] as LineRow[] } })
const reviewForm = useElForm({ defaults: { remark: '' } })
const group = useFormGroup({
base: baseForm,
lines: linesForm,
review: reviewForm,
})
async function submit() {
const [baseValid, linesValid, reviewValid] = await Promise.all([
baseHost.value?.validate(),
linesHost.value?.validate(),
reviewHost.value?.validate(),
])
if (!baseValid || !linesValid || !reviewValid)
return
await documentApi.save({ base: baseModel, lines: linesModel, review: reviewModel })
await group.submit(values => documentApi.save(values))
}
function openEdit(detail: GroupedDocument) {
Object.assign(baseModel, detail.base)
Object.assign(linesModel, detail.lines)
Object.assign(reviewModel, detail.review)
group.load('edit', detail)
}
function resetAll() {
baseHost.value?.resetFields()
linesHost.value?.resetFields()
reviewHost.value?.resetFields()
group.reset()
}<template>
<BaseSection ref="baseHost" v-model="baseModel" />
<LinesSection ref="linesHost" v-model="linesModel" />
<ReviewSection ref="reviewHost" v-model="reviewModel" />
<BaseSection :form="baseForm" />
<LinesSection :form="linesForm" />
<ReviewSection :form="reviewForm" />
<el-button :loading="group.submitting" @click="submit">Submit all</el-button>
</template>Each section remains independent
Each member still binds its own UI Form and rules.
useFormGroupdoes not create one giant host.group.validate()validates members concurrently, preserves errors in the owning section, and scrolls to the first invalid member.group.load()passes each value slice to the corresponding member. An omitted section returns to its factory defaults instead of retaining the previous record.If a child already owns its form, expose the minimal
FormGroupMembersurface. Do not let the parent mutate private child state.
5. Atomic editors: collapse coupled sections into one form
Reconstructed public example
The example below is synthetic. Routes, identifiers, field labels, API names, and payload shapes do not come from an application repository. It preserves only the engineering shape: several visual sections submit atomically, repeated rows have cross-row rules, and a small subset of fields is required for a server-side draft.
Use this approach when the sections are not independent forms. If each section has its own submit boundary, keep the separate hosts and use useFormGroup as shown above.
Section refs and row-local hosts duplicate the lifecycle
import { computed, reactive, ref, useTemplateRef, watch } from 'vue'
import { r, useElForm } from '@vformjs/element-plus'
interface VariantRow {
code: string
color: string
notes: string
attributes: Record<string, unknown>
}
interface EditorValues {
summary: {
code: string
notes: string
}
attributes: Record<string, unknown>
variants: VariantRow[]
}
interface EditorPayload {
header: EditorValues['summary']
fields: Record<string, unknown>
entries: VariantRow[]
}
function toPayload(values: EditorValues): EditorPayload {
return {
header: { ...values.summary },
fields: { ...values.attributes },
entries: values.variants.map(row => ({
code: row.code,
color: row.color,
notes: row.notes,
attributes: { ...row.attributes },
})),
}
}
const summaryRef = useTemplateRef<SectionHandle>('summaryRef')
const attributesRef = useTemplateRef<SectionHandle>('attributesRef')
const variantsRef = useTemplateRef<SectionHandle>('variantsRef')
const submitting = ref(false)
const savingDraft = ref(false)
const summaryModel = reactive({ code: '', notes: '' })
const variantRows = ref<VariantRow[]>([])
const colorRules = computed(() => [{
required: variantRows.value.some(row => Boolean(row.color)),
message: 'Required',
}])
watch(
() => summaryModel.notes,
(notes) => {
variantRows.value.forEach((row) => {
row.notes = notes
})
},
)
const form = useElForm<EditorValues>({
defaults: {
summary: { code: '', notes: '' },
attributes: {},
variants: [],
},
tracking: 'explicit',
rules: {
'summary.code': r.required(),
'variants.*.code': r.required(),
'variants.*.color': ({ values }) =>
values.variants.some(row => row.color) ? r.required() : null,
},
linkage: [
{
deps: ['summary.notes'],
run: ({ get, set, values }) => {
const notes = String(get('summary.notes') ?? '')
values.variants.forEach((_row, index) => {
set(`variants.${index}.notes`, notes)
})
},
},
],
})
const variants = form.list<VariantRow>('variants', {
defaultItem: () => ({
code: '',
color: '',
notes: form.model.summary.notes,
attributes: {},
}),
})
async function submit() {
const results = await Promise.allSettled([
summaryRef.value?.validate(),
attributesRef.value?.validate(),
variantsRef.value?.validate(),
])
if (results.some(result => result.status === 'rejected'))
return
submitting.value = true
try {
await editorApi.save({
summary: summaryRef.value?.getValues(),
attributes: attributesRef.value?.getValues(),
variants: variantsRef.value?.getValues(),
})
}
finally {
submitting.value = false
}
await form.submit(values => editorApi.save(toPayload(values)))
}
async function saveDraft() {
summaryRef.value?.clearValidate()
attributesRef.value?.clearValidate()
variantsRef.value?.clearValidate()
await Promise.all([
summaryRef.value?.validateField('code'),
variantsRef.value?.validateField('code'),
])
const result = await form.validateField([
'summary.code',
'variants.*.code',
])
if (!result.ok)
return
savingDraft.value = true
try {
await editorApi.saveDraft(toPayload(form.get()))
}
finally {
savingDraft.value = false
}
}<template>
<SummarySection ref="summaryRef" />
<AttributesSection ref="attributesRef" />
<VariantsSection ref="variantsRef" />
<el-form v-bind="form.host">
<SummarySection :form="form" />
<AttributesSection :form="form" />
<div v-for="row in variants.fields" :key="row.key">
<VariantSection :form="form" :index="row.index" />
<el-button @click="variants.remove(row.index)">Remove</el-button>
</div>
</el-form>
<el-button :loading="submitting" @click="submit">Submit</el-button>
<el-button :loading="form.submitting" @click="submit">Submit</el-button>
<el-button :loading="savingDraft" @click="saveDraft">Save draft</el-button>
</template>One owner, explicit boundaries
- One host owns validation order, errors, loading, and first-error scrolling.
form.list()keeps row keys outside submitted values and remaps row errors after insertion, removal, and movement.- Wildcard rules replace row-by-row validator registration. The conditional color rule activates for every row after any row supplies a color.
linkagemakes cross-section propagation explicit. Child sections render fields; they no longer expose lifecycle methods through component refs.toPayload()is the application-owned mapper defined above. Runtime field renderers, uploads, calculations, and transport-specific serialization do not move into vformjs.- A server-side draft is still an API action.
snapshotDraft()is a local, versioned snapshot and is not a replacement for that endpoint.
6. Let an AI Agent handle the “manual” cases
“Manual” in an audit report means a deterministic codemod cannot infer the semantics safely. It does not mean a person must type every edit. An Agent using the vformjs skill can read the complete component, callers, model/API types, child contracts, and tests before making a semantic migration.
| Work | Agent role | Maintainer gate |
|---|---|---|
| Regular form | Complete the clean cutover and verification | Confirm post-success page actions |
| Dynamic form | Map when, wildcard rules, list, and optionSources | Decide hidden-value and dependent-value reset semantics |
| Complex form | Migrate lifecycle, tracking, drafts, and API errors | Confirm domain calculations, payload transforms, and performance target |
| Multi-form | Create one form per host and compose with useFormGroup | Confirm section ownership and atomic-submit boundary |
| Custom UI | Classify A/B/C/D and implement an A/B adapter or C bridge | Stop for D-class parallel form engines |
Install the repository-matched skill into the target project:
pnpm dlx vformjs skill install
# Claude-specific location:
pnpm dlx vformjs skill install --agent claudeThen give the Agent a bounded, evidence-driven task:
Use the installed vformjs skill to migrate this form.
Read the complete component, every caller, child-form contract, model/API
types, and existing tests first. Report the A/B/C/D host class, the
regular/dynamic/complex/multi-form shape, and the observable behavior contract.
Resolve everything available in the repository. Ask only when hidden-value
policy, dependent-option reset, payload mapping, or section ownership has
multiple valid business meanings.
Make a clean cutover: migrate every caller and remove the old
model/rules/ref/reset/submit state machine. Run typecheck, the target build,
and the actual create/edit/reset/invalid-submit path. Public output must be
sanitized; keep the real business diff inside the authorized repository.The Agent still needs review gates:
No dual binding between the old model and
form.model.No simultaneous old
validate()and newform.submit().Dynamic forms must exercise hide/show, row move/remove, and stale options.
Multi-forms must exercise one invalid section, all-valid submit, and reset.
A build pass is not behavioral proof; run the actual form surface.
Public issues and docs use reconstructed examples, never application source.
The installed skill includes the full references/migration-workflow.md decision and verification workflow.
Which pages should migrate
Migrate when create, edit, reset, submit, and API-error state repeat across pages. Keep host-native forms for search-only, read-only, or one/two-field UI.
| Existing page | Recommendation |
|---|---|
| One host, static rules, standard CRUD | Good first migration |
| Conditional fields, dynamic rows, remote options | Map dependencies, then configure when, conditional rules, options, and form.list() |
| Large table or deeply nested model | Set tracking: 'explicit' before wiring fields |
| Coupled sections submitted atomically | Prefer one host; keep sections presentational and use form.list() for repeated rows |
| Independently valid sections | One form per section, composed with useFormGroup |
| Search-only, read-only, or one/two fields | Keep the host-native form; the benefit is usually too small |
Continue with: