Vben Form
Vben Form is the shared form abstraction used across different UI-library variants such as Ant Design Vue, Element Plus, Naive UI, and other adapters added inside this repository.
It uses TanStack Form internally for state and validation lifecycles, with Zod 4 schemas. Application code should continue using useVbenForm, FormApi, and the adapter layer instead of depending on the raw TanStack instance.
Read the Zod 4 and TanStack Form migration guide before upgrading an existing project.
If some details are not obvious from the docs, check the live demos as well.
Adapter Setup
Each app keeps its own adapter layer under src/adapter/form.ts and src/adapter/component/index.ts.
The current adapter pattern is:
- initialize the shared component adapter first
- call
setupVbenForm(...) - map special
v-model:*prop names throughmodelPropNameMap - keep the form empty state aligned with the actual UI library behavior
Form Adapter Example
import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { initComponentAdapter } from './component';
initComponentAdapter();
setupVbenForm<ComponentType>({
config: {
baseModelPropName: 'value',
emptyStateValue: null,
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
},
},
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;Component Adapter Example
import type { Component, SetupContext } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import { h } from 'vue';
import { globalShareState } from '@vben/common-ui';
import { $t } from '@vben/locales';
import {
AutoComplete,
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Divider,
Input,
InputNumber,
InputPassword,
Mentions,
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TimePicker,
TreeSelect,
Upload,
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
) => {
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
return h(component, { ...props, ...attrs, placeholder }, slots);
};
};
export type ComponentType =
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
AutoComplete,
Checkbox,
CheckboxGroup,
DatePicker,
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
};
globalShareState.setComponents(components);
globalShareState.defineMessage({
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };Basic Usage
Create the form through useVbenForm:
<script lang="ts" setup>
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const [BaseForm] = useVbenForm({
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// 提交函数
handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical
// 水平布局,label和input在同一行
layout: 'horizontal',
schema: [
{
// 组件需要在 #/adapter.ts内注册,并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'username',
// 界面显示的label
label: '字符串',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
fieldName: 'password',
label: '密码',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字(带后缀)',
suffix: () => '¥',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
fieldName: 'options',
label: '下拉选',
},
{
component: 'RadioGroup',
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
},
fieldName: 'radioGroup',
label: '单选组',
},
{
component: 'Radio',
fieldName: 'radio',
label: '',
renderComponentContent: () => {
return {
default: () => ['Radio'],
};
},
},
{
component: 'CheckboxGroup',
componentProps: {
name: 'cname',
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
},
fieldName: 'checkboxGroup',
label: '多选组',
},
{
component: 'Checkbox',
fieldName: 'checkbox',
label: '',
renderComponentContent: () => {
return {
default: () => ['我已阅读并同意'],
};
},
},
{
component: 'Mentions',
componentProps: {
options: [
{
label: 'afc163',
value: 'afc163',
},
{
label: 'zombieJ',
value: 'zombieJ',
},
],
placeholder: '请输入',
},
fieldName: 'mentions',
label: '提及',
},
{
component: 'Rate',
fieldName: 'rate',
label: '评分',
},
{
component: 'Switch',
componentProps: {
class: 'w-auto',
},
fieldName: 'switch',
label: '开关',
},
{
component: 'DatePicker',
fieldName: 'datePicker',
label: '日期选择框',
},
{
component: 'RangePicker',
fieldName: 'rangePicker',
label: '范围选择器',
},
{
component: 'TimePicker',
fieldName: 'timePicker',
label: '时间选择框',
},
{
component: 'TreeSelect',
componentProps: {
allowClear: true,
placeholder: '请选择',
showSearch: true,
treeData: [
{
label: 'root 1',
value: 'root 1',
children: [
{
label: 'parent 1',
value: 'parent 1',
children: [
{
label: 'parent 1-0',
value: 'parent 1-0',
children: [
{
label: 'my leaf',
value: 'leaf1',
},
{
label: 'your leaf',
value: 'leaf2',
},
],
},
{
label: 'parent 1-1',
value: 'parent 1-1',
},
],
},
{
label: 'parent 2',
value: 'parent 2',
},
],
},
],
treeNodeFilterProp: 'label',
},
fieldName: 'treeSelect',
label: '树选择',
},
],
wrapperClass: 'grid-cols-1',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
</script>
<template>
<BaseForm />
</template>Typed Values and Slots
Use useVbenForm<TFormValues, TSubmitValues> to declare component-facing form values and submission values separately. Schema, slots, selectors, and setValues use TFormValues; getValues() and submit() return Promise<TSubmitValues>, while submit() only accepts an optional native Event; the first handleSubmit argument is TSubmitValues. Pass one generic when both shapes are identical.
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values) {
return addAccount(values); // AccountFormValues
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
return formApi.getValues(); // Promise<AccountFormValues>
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.value and componentField.modelValue are strings -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>Named field slots expose field, componentField, modelValue, name, disabled, isInValid, values, and formApi. The default slot exposes shapes, values, and formApi; action slots expose values and formApi. Forms without an explicit TValues remain compatible with arbitrary slot names and broad props.
Form Codec
Use the form-level codec when component values and the backend payload have different shapes. encode converts the complete TFormValues object to TSubmitValues; decode performs the inverse conversion. Multi-field splits and merges are atomic and do not depend on schema order or string-path writes.
Define codec directly in the useVbenForm options. Annotate only the form-value input of encode; TSubmitValues is inferred from its return object and flows into decode, getValues(), and submit callbacks:
const [Form, formApi] = useVbenForm({
codec: {
decode(values) {
return { period: [values.startTime, values.endTime] };
},
encode(values: Readonly<FormValues>) {
return {
endTime: values.period[1],
startTime: values.period[0],
};
},
},
schema,
});<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { Button, Card, message, Space, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
interface ValueFormatFormValues {
firstName?: string;
lastName?: string;
tags?: string[];
}
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
return {
fullName: [values.firstName, values.lastName].filter(Boolean).join(' '),
tags: (values.tags ?? []).join(','),
};
}
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
function decodeValueFormatValues(
values: Readonly<ValueFormatSubmitValues>,
): ValueFormatFormValues {
const [firstName = '', ...lastNameParts] = values.fullName
.trim()
.split(/\s+/);
return {
firstName,
lastName: lastNameParts.join(' '),
tags: values.tags ? values.tags.split(',') : [],
};
}
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
const liveValues = ref<Partial<ValueFormatFormValues>>({});
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
handleValuesChange,
schema: [
{
component: 'Input',
fieldName: 'firstName',
help: '与姓氏一起编码为 fullName',
label: '名字',
},
{
component: 'Input',
fieldName: 'lastName',
help: '与名字一起编码为 fullName',
label: '姓氏',
},
{
component: 'Select',
componentProps: {
mode: 'multiple',
options: [
{ label: '管理员', value: 'admin' },
{ label: '审核员', value: 'reviewer' },
{ label: '访客', value: 'guest' },
],
placeholder: '请选择标签',
},
fieldName: 'tags',
help: '数组编码为逗号分隔字符串',
label: '标签',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
const liveValuesPreview = computed(() => formatJsonPreview(liveValues.value));
const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: unknown) {
return JSON.stringify(value, null, 2);
}
async function handleInspectValues() {
await syncPreviewValues();
message.success('已刷新 getValues 输出');
}
async function handleSetSubmitValues() {
await formApi.setSubmitValues({
fullName: 'Ada Lovelace',
tags: 'admin,reviewer',
});
await syncPreviewValues();
message.success('已通过 codec.decode 回填提交值');
}
function handleSubmit(values: ValueFormatSubmitValues) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
function handleValuesChange(
values: Readonly<ValueFormatFormValues>,
_fieldsChanged: string[],
getFormattedValues: () => ValueFormatSubmitValues,
) {
liveValues.value = { ...values };
transformedValues.value = getFormattedValues();
}
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
const rawValues = values ?? (await formApi.getRawValues());
liveValues.value = { ...rawValues };
transformedValues.value = await formApi.getValues();
}
onMounted(async () => {
await nextTick();
await syncPreviewValues();
});
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<Tag color="processing">encode:生成完整提交值</Tag>
<Tag color="success">decode:恢复完整表单值</Tag>
<Tag color="warning">多字段转换原子执行</Tag>
</div>
<Card title="Codec 示例">
<template #extra>
<Space wrap>
<Button @click="handleSetSubmitValues">从提交值回填</Button>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
</Space>
</template>
<Form />
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="getRawValues() 输出(组件值)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>
</Card>
</div>
</div>
</template>schema.valueFormat, fieldMappingTime, and arrayToStringFields remain runtime-compatible but are deprecated. When a codec is configured it takes precedence and deprecated transforms are ignored.
Performance Benchmarks
The form benchmarks cover component initialization, single-field and batch updates, reset, Zod validation, dynamic schemas, dependencies, codec encoding and snapshots, plus array editing, row mutations, and child-schema updates. Run the complete benchmark suite with:
pnpm test:benchmarkTo run only the form benchmarks, pass both files explicitly:
pnpm exec vitest bench --run packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.tsUse benchmark results to compare relative changes on the same machine and runtime; do not treat one run's absolute timings as portable thresholds. Stop CPU-intensive development servers first and keep the Node.js version consistent. Benchmark files are not included in the regular test:unit command.
Mounted form context
formApi.form is the FormContextApi injected after <Form /> mounts. Do not destructure or cache form from the second useVbenForm return value during setup, because that captures the pre-mount empty reference. Prefer mount-aware public methods such as getRawValues(), setFieldError(), setFieldValue(), and validate() for business actions. Access fine-grained subscription methods on formApi.form only from an already-mounted form context.
Key API Notes
useVbenFormreturns[Form, formApi]useVbenForm<TFormValues, TSubmitValues>keeps component values and submission values distinct- prefer
reset,submit,validateAndSubmit, andclearValidation resetForm,submitForm,validateAndSubmitForm, andresetValidateremain deprecated aliases that warn once in developmentclearValidationinvalidates in-flight async results before clearing errorsformApi.getFieldComponentRef()andformApi.getFocusedField()are available in current versionshandleValuesChange(values, fieldsChanged)receives readonlyTFormValuesbefore codec or legacy formatting- its third
getFormattedValuesargument formats lazily, so raw-only change handlers avoid clone and transform work getRawValues()returns only an independent raw snapshot,getValues()returns only the formatted payload, andgetValueSnapshot()returns bothhandleSubmit(values, rawValues)receives the formatted payload and its corresponding raw snapshotfieldMappingTime,arrayToStringFields, andschema.valueFormatare deprecated compatibility optionscodec.encodedefines thegetValues()payload andcodec.decodepowers completesetSubmitValues()fillsformApi.formexposes the mountedFormContextApi; do not destructure or cache it before<Form />mounts- prefer
formApi.form.useFieldValue,useFieldValues, anduseFieldErrorfor fine-grained subscriptions; useuseValuesonly when the whole form is required useSelectorremains the compatibility selector for combined{ values, errors, meta }state- legacy
setupVbenForm({ defineRules })still works, warns once in development, and is silent in production; userulesfor new code - prefer
dependencies: { triggerFields, resolve(context) }for one atomic dynamic-state patch; legacy dependency callbacks remain supported but are deprecated and warn once in development - top-level
componentProps,help, andrenderComponentContentfunctions receiveFormSchemaContext; value-dependent rendering belongs independencies.resolve - use
formFieldProps.validateOnwithblurand/orchange; submit always validates, andasyncDebounceMsdebounces async validators - use
changeEventFallback: trueonly for components that emitchangewithout anupdate:*event
Reference
For the complete Chinese API tables and more examples, see the Chinese component page if you need the full parameter matrix.

vben
dream-weave
xingyu4j
Jin Mao
Li Kui