Skip to content

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 shapeOne form instance ownsBusiness code keeps
Regular CRUDDefaults, mode, validation, reset, submit stateAPI and post-success page actions
Dynamic formActive conditions and rules, stable row keys, error remap, option requestsDomain predicates and data endpoints
Large formExplicit tracking, array operations, drafts, API field errorsLayout, calculations, uploads, payload transforms
Multi-section formAggregate validation, mode, errors, submit, resetSection 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

vue
<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: undefined in defaults; otherwise create mode can retain the previous edit identifier.
  • Keep the host-native prop at level 1. Switch to form.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

ts
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') 
vue
<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

  • options resets the dependent value, aborts superseded requests, and keeps only the latest result. Business code still supplies the domain endpoint.
  • Keys from contacts.fields never enter submitted values. Existing field errors follow the matching business row through move and remove operations.
  • when controls visibility; conditional callbacks in rules control active validation. Hidden fields leave validation with their rules removed.

Track fields explicitly

ts
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) 
}
vue
<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 through form.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

ts
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() 
}
vue
<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. useFormGroup does 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 FormGroupMember surface. 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

ts
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
  }
}
vue
<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.
  • linkage makes 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.

WorkAgent roleMaintainer gate
Regular formComplete the clean cutover and verificationConfirm post-success page actions
Dynamic formMap when, wildcard rules, list, and optionSourcesDecide hidden-value and dependent-value reset semantics
Complex formMigrate lifecycle, tracking, drafts, and API errorsConfirm domain calculations, payload transforms, and performance target
Multi-formCreate one form per host and compose with useFormGroupConfirm section ownership and atomic-submit boundary
Custom UIClassify A/B/C/D and implement an A/B adapter or C bridgeStop for D-class parallel form engines

Install the repository-matched skill into the target project:

bash
pnpm dlx vformjs skill install
# Claude-specific location:
pnpm dlx vformjs skill install --agent claude

Then give the Agent a bounded, evidence-driven task:

text
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 new form.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 pageRecommendation
One host, static rules, standard CRUDGood first migration
Conditional fields, dynamic rows, remote optionsMap dependencies, then configure when, conditional rules, options, and form.list()
Large table or deeply nested modelSet tracking: 'explicit' before wiring fields
Coupled sections submitted atomicallyPrefer one host; keep sections presentational and use form.list() for repeated rows
Independently valid sectionsOne form per section, composed with useFormGroup
Search-only, read-only, or one/two fieldsKeep the host-native form; the benefit is usually too small

Continue with:

MIT licensed. Built for forms that already have a UI.