[Vue3] Dynamic Form Design: Implementation and Optimization with Linked List Structure
In complex business scenarios, form items are often not static but need to dynamically show or hide based on conditions. For example, in a form, OtherFavorite needs to be displayed only when the value of Favorite includes “Other.” A common approach is to control visibility using if conditions, but as the number of dynamic form items increases, code complexity grows significantly, making it harder to maintain.
This article introduces a more flexible design using a linked list structure to address the challenges of dynamic form rendering and validation.
Problem Background
Below is an example of a simple form requirement:
| Order | Field | Type | Value | Display Condition |
|---|---|---|---|---|
| 1 | Name | text | string | Always visible |
| 2 | Address | text | string | Always visible |
| 3 | Favorite | checkbox | [“Apple”, “Banana”, “Cherry”, “Other”] | Always visible |
| 4 | OtherFavorite | text | string | Displayed when Favorite includes “Other” |
| 5 | Comment | textarea | string | Always visible |
A typical implementation might look like this, using v-if to dynamically display OtherFavorite and watch to adjust validation rules dynamically:
<template>
<el-form
ref="ruleFormRef"
style="max-width: 600px"
:model="ruleForm"
:rules="rules"
label-width="auto"
:validate-on-rule-change="false"
status-icon
>
<el-form-item label="Name" prop="name">
<el-input v-model="ruleForm.name" />
</el-form-item>
<el-form-item label="Address" prop="address">
<el-input v-model="ruleForm.address" />
</el-form-item>
<el-form-item label="Favorite" prop="favorite">
<el-checkbox-group v-model="ruleForm.favorite">
<el-checkbox label="Apple" value="Apple" />
<el-checkbox label="Banana" value="Banana" />
<el-checkbox label="Cherry" value="Cherry" />
<el-checkbox label="Other" value="Other" />
</el-checkbox-group>
</el-form-item>
<!-- Dynamically show OtherFavorite based on Favorite's value -->
<el-form-item v-if="ruleForm.favorite.includes('Other')" label="OtherFavorite" prop="other">
<el-input v-model="ruleForm.other" />
</el-form-item>
<el-form-item label="Comment" prop="comment">
<el-input v-model="ruleForm.comment" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm(ruleFormRef)"> Create </el-button>
<el-button @click="resetForm(ruleFormRef)">Reset</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import type { FormInputs } from '@/models/form.model';
const ruleFormRef = ref<FormInstance>();
const ruleForm = ref<FormInputs>({
name: '',
address: '',
favorite: [],
other: '',
comment: '',
});
const rules = ref<FormRules<FormInputs>>({
name: [{ required: true, message: 'Please input name', trigger: 'blur' }],
address: [{ required: true, message: 'Please input address', trigger: 'blur' }],
favorite: [{ required: true, message: 'Please select favorite', trigger: 'change' }],
comment: [{ required: true, message: 'Please input comment', trigger: 'blur' }],
});
// Watch Favorite's value to dynamically set validation rules for Other
watch(
() => ruleForm.value.favorite,
(value) => {
if (value.includes('Other')) {
rules.value.other = [
{ required: true, message: 'Please input other favorite', trigger: 'blur' },
];
} else {
rules.value.other = [];
}
},
);
const submitForm = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
console.log('submit!');
} else {
console.log('error submit!', fields);
}
});
};
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.resetFields();
};
</script>
While this method works for simple cases, as the number of dynamic fields and conditions increases, the code becomes harder to maintain.
Solution: Linked List Structure
We can abstract each form item as a linked list node, where each node contains the following information:
- Basic form item details (e.g., id, label, type, etc.).
- Logic for dynamic visibility conditions.
- Pointer to the next node(s).
Data Structure Design
Define metadata types for form items:
import type { FormItemRule } from 'element-plus';
/**
* Form input types
*/
export type FormInputs = {
name: string;
address: string;
favorite: string[];
other?: string;
comment: string;
};
/**
* Metadata for form items
*/
export type FormItemMeta = {
/** Form item ID, must match a key from FormInputs */
id: keyof FormInputs;
/** Form item label */
label: string;
/** Form item type */
type: 'text' | 'checkbox' | 'textarea';
/** Validation rules for the form item */
rules: Array<FormItemRule> | undefined;
/** Options for checkboxes */
options?: Array<{ label: string; value: string }>;
/** Next form item(s) */
next?: FormItemMeta | Array<{ condition: string; item: FormItemMeta } | FormItemMeta>;
};
Using the structure above, we can organize form items as a linked list:
export const formItemMeta: FormItemMeta = {
id: 'name',
label: 'Name',
type: 'text',
rules: [{ required: true, message: 'Please input name', trigger: 'blur' }],
// The next form item after "name" is "address"
next: {
id: 'address',
label: 'Address',
type: 'text',
rules: [{ required: true, message: 'Please input address', trigger: 'blur' }],
// The next form item after "address" is "favorite"
next: {
id: 'favorite',
label: 'Favorite',
type: 'checkbox',
rules: [{ required: true, message: 'Please select favorite', trigger: 'change' }],
options: [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Other', value: 'other' },
],
// The next form items after "favorite" are "other" and "comment"
next: [
// "OtherFavorite" is displayed only when Favorite includes "Other"
{
condition: 'other',
item: {
id: 'other',
label: 'Other',
type: 'text',
rules: [{ required: true, message: 'Please input other', trigger: 'blur' }],
},
},
// "Comment" is always displayed
{
id: 'comment',
label: 'Comment',
type: 'textarea',
rules: [{ required: true, message: 'Please input comment', trigger: 'blur' }],
},
],
},
},
};
Rendering Logic: Recursive Implementation
Define a recursive component FormItemRenderer.vue to dynamically render form items based on metadata.
<template>
<el-form
ref="ruleFormRef"
style="max-width: 600px"
:model="form"
:rules="rules"
label-width="auto"
:validate-on-rule-change="false"
status-icon
>
<FormItemRenderer
:meta="formItemMeta"
:values="form"
v-on:value-changed="onValueChanged"
v-on:rule-changed="onRuleChanged"
/>
<el-form-item>
<el-button type="primary" @click="submitForm(ruleFormRef)"> Create </el-button>
<el-button @click="resetForm(ruleFormRef)">Reset</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import type { FormInstance, FormItemRule, FormRules } from 'element-plus';
import type { FormInputs } from '@/models/form.model';
import FormItemRenderer from './FormItemRenderer.vue';
import { formItemMeta } from '@/models/form.model';
const ruleFormRef = ref<FormInstance>();
const form = ref<FormInputs>({
name: '',
address: '',
favorite: [],
other: '',
comment: '',
});
const rules = ref<FormRules<FormInputs>>({});
const onValueChanged = (id: string, value: string | string[] | undefined) => {
form.value = {
...form.value,
[id]: value,
};
};
const onRuleChanged = (id: string, itemRules: Array<FormItemRule> | undefined) => {
rules.value = {
...rules.value,
[id]: itemRules,
};
};
const submitForm = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
console.log('submit!');
} else {
console.log('error submit!', fields);
}
});
};
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.resetFields();
};
</script>
Summary
Using a linked list structure for dynamic forms simplifies code logic and improves scalability and maintainability. Complex visibility and validation rules can be easily defined and updated through metadata.