Build a Form Generator

Building forms is often a repetitive task and requires a lot of back and forth to maintain. Maybe your client asked to add a field, maybe they asked to remove a field.

For most cases using static markup is good enough for your form needs but in some cases, it would be great if you had a dynamic form generator that would quickly render your fields based on some JSON schema.

In this tutorial, you will learn how to use vee-validate to build a form-generator without external libraries.

Let's quickly recap what you will be building, the component we will be building should:

  • Accept a form schema specifying the fields
  • Render the given schema
  • Use yup to validate our form
  • Show error messages

tip

This guide will cover how to build a basic form generator.

If you are looking for a more robust solution for form generation, take a look at Formvuelate, and it has first-party vee-validate support.

Prerequisites

This tutorial assumes you know:

This tutorial also assumes you already have an empty Vue-cli project that you will edit as you follow along and that you have installed vee-validate already.

Laying The Foundation

Before getting to the implementation details to implement a dynamic form generator, you need to have an overview of how it would work.

So let's assume we have already implemented a component called DynamicForm that accepts a schema prop that has all the information needed to render the form.

We have a few requirements to fulfill:

  • Should be able to provide the fields.
  • Should be able to specify types (elements) for those fields
  • Should be able to specify validation either on field-level or form-level

Assuming we have such a component, we can imagine using it to be like this:

App.vuevue<template>
  <DynamicForm :schema="formSchema" />
</template>

<script>
import DynamicForm from '@/components/DynamicForm.vue';

export default {
  components: {
    DynamicForm,
  },
  data: () => {
    const formSchema = {
      fields: [
        {
          label: 'Your Name',
          name: 'name',
          as: 'input',
        },
        {
          label: 'Your Email',
          name: 'email',
          as: 'input',
        },
        {
          label: 'Your Password',
          name: 'password',
          as: 'input',
        },
      ],
    };

    return {
      formSchema,
    };
  },
};
</script>

The form schema will contain fields property which is an array of the fields we want to render, each field entry will have these properties:

  • label: a friendly label to display with the input.
  • name: a unique name for the field to identify it.
  • as: the name of the input element to render, it can be any native HTML element.

Rendering Fields

The initial implementation will follow these generic steps:

  • Use Form component from vee-validate to render the form
  • Iterate over each field in schema.fields
  • Render each field as a Field component passing all props to it

Let's put that into some code.

components/DynamicForm.vuevue<template>
  <Form>
    <div
      v-for="field in schema.fields" :key="field.name">
      <label :for="field.name">{{ field.label }}</label>
      <Field :as="field.as" :id="field.name" :name="field.name" />
    </div>

    <button>Submit</button>
  </Form>
</template>

<script>
import { Form, Field } from 'vee-validate';

export default {
  name: 'DynamicForm',
  components: {
    Form,
    Field,
  },
  props: {
    schema: {
      type: Object,
      required: true,
    },
  },
};
</script>

Notice that when you run the example, the password field is being rendered as a text field which isn't ideal. We would like to be able to pass the type property to the input element as well.

In the App.vue component, add highlighted line:

App.vuevue<template>
  <DynamicForm :schema="formSchema" />
</template>

<script>
import DynamicForm from '@/components/DynamicForm.vue';

export default {
  components: {
    DynamicForm,
  },
  data: () => {
    const formSchema = {
      fields: [
        {
          label: 'Your Name',
          name: 'name',
          as: 'input',
        },
        {
          label: 'Your Email',
          name: 'email',
          as: 'input',
        },
        {
          label: 'Your Password',
          name: 'password',
          as: 'input',
          type: 'password'
        },
      ],
    };

    return {
      formSchema,
    };
  },
};
</script>

In the DynamicForm.vue component, update the iteration with v-for portion to extract the known keys that we expect and collecting the rest in another object using ES6 object rest operator.

components/DynamicForm.vuevue<template>
  <Form>
    <div
      v-for="{ as, name, label, ...attrs } in schema.fields"
      :key="name"
    >
      <label :for="name">{{ label }}</label>
      <Field :as="as" :id="name" :name="name" v-bind="attrs" />
    </div>

    <button>Submit</button>
  </Form>
</template>

<script>
import { Form, Field } from 'vee-validate';

export default {
  name: 'DynamicForm',
  components: {
    Form,
    Field,
  },
  props: {
    schema: {
      type: Object,
      required: true,
    },
  },
};
</script>

The v-bind there allows us to bind everything in the attrs object which is all the other props we didn't extract explicitly and bind them to the Field component.

The Field component will pass down any props that it doesn't accept to whatever gets rendered in its place, effectively passing down other attributes to our input tags.

Bonus: Adding support for slotted inputs

The select input introduces an edge case where your field would need to have child elements (i.e: <option> elements) inside its slot. Let's tackle this edge case head-on.

Add a new fourth entry to the form's schema. This new entry will have a new children property that contains the options we want to render in the select element.

App.vuevue<template>
  <DynamicForm :schema="formSchema" />
</template>

<script>
import DynamicForm from '@/components/DynamicForm.vue';

export default {
  components: {
    DynamicForm,
  },
  data: () => {
    const formSchema = {
      fields: [
        // ...
        {
          label: 'Favorite Drink',
          name: 'drink',
          as: 'select',
          children: [
            {
              tag: 'option',
              value: '',
              text: '',
            },
            {
              tag: 'option',
              value: 'coffee',
              text: 'Coffeee',
            },
            {
              tag: 'option',
              value: 'tea',
              text: 'Tea',
            },
            {
              tag: 'option',
              value: 'coke',
              text: 'Coke',
            },
          ],
        },
      ],
    };

    return {
      formSchema,
    };
  },
};
</script>

Similar to how we render fields we will iterate on the children property if it exists inside the default slot for the <Field /> component.

components/DynamicForm.vuevue<template>
  <Form>
    <div
      v-for="{ as, name, label, children, ...attrs } in schema.fields"
      :key="name"
    >
      <label :for="name">{{ label }}</label>
      <Field :as="as" :id="name" :name="name" v-bind="attrs">
        <template v-if="children && children.length">
          <component v-for="({ tag, text, ...childAttrs }, idx) in children"
            :key="idx"
            :is="tag"
            v-bind="childAttrs"
          >
            {{ text }}
          </component>
        </template>
      </Field>
    </div>

    <button>Submit</button>
  </Form>
</template>

<script>
import { Form, Field } from 'vee-validate';

export default {
  name: 'DynamicForm',
  components: {
    Form,
    Field,
  },
  props: {
    schema: {
      type: Object,
      required: true,
    },
  },
};
</script>

The template started to get a little bit more complex, so we will stop there but now we have support for select elements and any other type of inputs you may need.

Handling Validation

We would like each field to have its own validation rules defined on the schema. We will use yup for those validation rules.

In App.vue, update the form's schema so that each field has a new rules property with sensible validation rules

App.vuevue<template>
  <DynamicForm :schema="formSchema" />
</template>

<script>
import DynamicForm from '@/components/DynamicForm.vue';
import * as Yup from 'yup';

export default {
  components: {
    DynamicForm,
  },
  data: () => {
    const formSchema = {
      fields: [
        {
          label: 'Your Name',
          name: 'name',
          as: 'input',
          rules: Yup.string().required(),
        },
        {
          label: 'Your Email',
          name: 'email',
          as: 'input',
          rules: Yup.string().email().required(),
        },
        {
          label: 'Your Password',
          name: 'password',
          as: 'input',
          type: 'password',
          rules: Yup.string().min(6).required(),
        },
      ],
    };

    return {
      formSchema,
    };
  },
};
</script>

Now that each field has its own validation rules, we will need to display the error messages.

Import and register the ErrorMessage component inside the DynamicForm.vue component, and add it to the template after the <Field />.

components/DynamicForm.vuevue<template>
  <Form>
    <div
      v-for="{ as, name, label, children, ...attrs } in schema.fields"
      :key="name"
    >
      <label :for="name">{{ label }}</label>
      <Field :as="as" :id="name" :name="name" v-bind="attrs">
        <template v-if="children && children.length">
          <component v-for="({ tag, text, ...childAttrs }, idx) in children"
            :key="idx"
            :is="tag"
            v-bind="childAttrs"
          >
            {{ text }}
          </component>
        </template>
      </Field>
      <ErrorMessage :name="name" />
    </div>

    <button>Submit</button>
  </Form>
</template>

<script>
import { Form, Field, ErrorMessage } from 'vee-validate';

export default {
  name: 'DynamicForm',
  components: {
    Form,
    Field,
    ErrorMessage
  },
  props: {
    schema: {
      type: Object,
      required: true,
    },
  },
};
</script>

And that's it, you should have validation working now 🎉

Demo

You can check a live sample of what we did here.

Conclusion

In this guide, you learned how to use the dynamic rendering capabilities of Vue.js combined with the flexible nature of the vee-validate components. You created a form that renders fields and validates them based on a JSON schema.

While the finished product is far from being complete, you can add features as needed to your form generator.

tip

If you are looking for a more robust solution for form generation, take a look at Formvuelate, and it has first-party vee-validate support.