The (al)most complete dynamic form with VeeValidate and Composition API

If you tried to incorporate validation to your forms, you've probably stumbled upon libraries like Vuelidate and VeeValidate - both very powerful libraries, but currently only VeeValidate supports 3rd version of Vue in stable version. Recently I have tried to make a dynamic form with validation, and I have encountered a number of problems that are tricky to solve.

Before diving in, let me list things that were required for my dynamic form:

  • field validation
  • nested fields support
  • field array support
  • schema update support
  • custom input support

Real-world example

We are going to implement a survey for frontend developers. In the survey we're going to ask what frameworks a user has experience with and ask some questions for selected items including experience details.

The workflow will be as follows:

  1. user selects frameworks
  2. for each selected framework appropriate inputs appear
  3. for each selected framework button appears that create inputs for new job experience item

Here is form's image.

Dynamic Form Screenshot

NOTE: for this example I'm going to use vite and Tailwind UI (hence the looks), but it is applicable for other toolings like vue-cli and nuxt, so don't mind the presence of Vue SFC file extensions in imports - vite requires that they are present. Also I'm going to use lodash functions, but you can use native functions or copy from lodash if you can't use it.

Schema format

Each field (NOT field array) in a schema should have:

  1. id - this will be used for recursive field component
  2. name - for vee-validate to initialize schema
  3. component - which component to render
  4. label - to show label for input (you can use it for placeholders or have separate field for it)
  5. rules - validation rules (going to use yup here)
  6. initialValue - initial value for input
  7. options - options for select component

For field array schema should have:

  1. name
  2. label
  3. fields - list of fields for new items each having the same schema listed above

So, in case of our example, the schema will be as following:

import { array } from 'yup';

const schema = {
    frameworks: {
        id: 1,
        label: "Which frameworks do you have experience with?",
        component: "CheckboxGroup",
        name: "frameworks",
        value: [],
        rules: array()
        .of(string())
        .min(1, "Please, select one of frameworks"),
        options: frameworks,
    },
    data: {},
}

data is going to contain generated schema for each selected framework similar to following:

schema.data = {
    vue: {
        opinion: {
            id: 2,
            label: `What is your opinion on Vue?`,
            component: "Textarea",
            name: `data.vue.opinion`,
            value: "",
            rules: string().required("Field is required"),
        },
        experience: {
            name: "data.experience",
            label: "Past experience",
            addLabel: "Add past experience",
            fields: [
                {
                    id: 3,
                    label: "Framework",
                    component: "Select",
                    name: "framework",
                    value: "vue",
                    options: pick(frameworks, values.frameworks),
                },
                {
                    id: 4,
                    label: "Title",
                    component: "TextInput",
                    name: "title",
                    value: "",
                    rules: string().required("Field is required"),
                },
                {
                    id: 5,
                    label: "Company",
                    component: "TextInput",
                    name: "company",
                    value: "",
                    rules: string().required("Field is required"),
                },
                {
                    id: 6,
                    label: "Start date (year)",
                    component: "TextInput",
                    name: "start",
                    value: "",
                    rules: string().required("Field is required"),
                },
                {
                    id: 7,
                    label: "End date (year)",
                    component: "TextInput",
                    name: "end",
                    value: "",
                    rules: string().required("Field is required"),
                },
            ],
        }
    }
}

You can also add any fields you want.

Main page component

The main page should contain the schema mentioned above and listen to changes in form values. If the value of frameworks field changes, it should add or remove fields based on value.

<Form @change="updateSchema" />

...

import { ref } from 'vue';
import { string } from 'yup';
import isEqual from 'lodash-es/isEqual';
import cloneDeep from 'lodash-es/cloneDeep';
import forEach from 'lodash-es/forEach';
import has from 'lodash-es/has';

const updateSchema = (values) => {
    if (!isEqual(values.frameworks, selectedFrameworks)) {
        selectedFrameworks = values.frameworks;
        const newSchema = cloneDeep(schema);
        forEach(values.frameworks, (framework) => {
            newSchema.data[framework] = {
                opinion: {
                    id: 2,
                    label: `What is your opinion on ${frameworks[framework].label}?`,
                    component: "Textarea",
                    name: `data.${framework}.opinion`,
                    value: "",
                    rules: string().required("Field is required"),
                },
            };
        });
        if (values.frameworks.length) {
            newSchema.data.experience = {
                name: "data.experience",
                label: "Past experience",
                addLabel: "Add past experience",
                fields: [
                    {
                        id: 3,
                        label: "Framework",
                        component: "Select",
                        name: "framework",
                        value: values.frameworks[0],
                        options: pick(frameworks, values.frameworks),
                    },
                    {
                        id: 4,
                        label: "Title",
                        component: "TextInput",
                        name: "title",
                        value: "",
                        rules: string().required("Field is required"),
                    },
                    {
                        id: 5,
                        label: "Company",
                        component: "TextInput",
                        name: "company",
                        value: "",
                        rules: string().required("Field is required"),
                    },
                    {
                        id: 6,
                        label: "Start date (year)",
                        component: "TextInput",
                        name: "start",
                        value: "",
                        rules: string().required("Field is required"),
                    },
                    {
                        id: 7,
                        label: "End date (year)",
                        component: "TextInput",
                        name: "end",
                        value: "",
                        rules: string().required("Field is required"),
                    },
                ],
            };
        }
        reactiveSchema.value = newSchema;
    }
};

Form component

We are going to use useForm in this component just for accumulating values and checking if the whole form is valid. Validation is going to be initiated in each form field instead.

We are going to watch changes to form values and emit change event to update schema.

Since one of the requirements for form is support of schema with any nesting depth, we are going to create a separate field component that is going to be recursive.

<Field :field="schema" />

...

import { useForm } from 'vee-validate';

export default {
    props: {
        schema: {
            type: Object,
            required: true,
        },
    },
    setup() {
        const { values, handleSubmit, meta } = useForm();

        watch(values, (newValues) => {
            emit('change', newValues);
        });
    }
}

Field component

These are the conditions for component recursion:

  • If current node contains id - render field component
  • If current node contains addLabel - create array field and add recursion for each added item
  • Else add recursion for each field child
<template>
    <div v-if="hasId()" class="sm:col-span-6">
        <component
            :is="field.component"
            v-bind="field"
            v-model="value"
            :error="errorMessage"
            @blur="handleBlur"
        ></component>
    </div>
    <template v-else-if="isArray()">
        <h4 v-show="fields.length" class="font-medium sm:col-span-6">
            
        </h4>
        <div class="sm:col-span-6 space-y-6 divide-y">
            <div
                v-for="(childField, index) in fields"
                :key="childField.key"
                class="space-y-6"
            >
            <Field
                v-for="formField in field.fields"
                :key="formField.id"
                :field="{
                    ...formField,
                    name: `${field.name}[${index}].${formField.name}`,
                }"
            />
            <button
                type="button"
                class="
                    inline-flex
                    items-center
                    px-4
                    py-2
                    border border-transparent
                    text-sm
                    font-medium
                    rounded-md
                    text-indigo-700
                    bg-indigo-100
                    hover:bg-indigo-200
                    focus:outline-none
                    focus:ring-2
                    focus:ring-offset-2
                    focus:ring-indigo-500
                "
                @click="remove(index)"
            >
                <XIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
                Remove
            </button>
        </div>
    </div>
    <div class="sm:col-span-6">
        <button
            type="button"
            class="
                inline-flex
                items-center
                px-4
                py-2
                border border-transparent
                shadow-sm
                text-sm
                font-medium
                rounded-md
                text-white
                bg-indigo-600
                hover:bg-indigo-700
                focus:outline-none
                focus:ring-2
                focus:ring-offset-2
                focus:ring-indigo-500
            "
            @click="push"
        >
            <PlusIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
            Add
        </button>
    </div>
    </template>
    <template
        v-for="(childField, index) in field"
        v-else
        :key="childField.id || index"
    >
        <Field :field="childField" />
    </template>
</template>

<script>
import { useField, useFieldArray } from "vee-validate";
import has from "lodash-es/has";
import fromPairs from "lodash-es/fromPairs";
import map from "lodash-es/map";
import { PlusIcon, XIcon } from "@heroicons/vue/solid";
// Don't forget to import your custom components

export default {
    name: "Field",
    // Don't forget to declare your custom components
    components: { PlusIcon, XIcon, },
    props: {
        field: {
            type: Object,
            required: true,
        },
    },
    setup(props) {
        const hasId = () => has(props.field, "id");
        const isArray = () => has(props.field, "addLabel");
        const result = { hasId, isArray };
        if (hasId()) {
            const { value, handleBlur, errorMessage } = useField(
                props.field.name,
                props.field.validation
            );
            result.value = value;
            result.handleBlur = handleBlur;
            result.errorMessage = errorMessage;
        }
        if (isArray()) {
            const { remove, push, fields } = useFieldArray(props.field.name);
            const getInitialValues = () =>
            fromPairs(
                map(props.field.fields, (field) => [field.name, field.value])
            );
            result.remove = (index) => {
                remove(index);
            };
            result.push = () => {
                push(getInitialValues());
            };
            result.fields = fields;
        }
        return result;
    },
};
</script>

Demo

Here are the demo and source code