Forms at Scale: A Component-Driven Approach

Every Vue developer eventually faces the same wall: a form that starts as a handful of fields and grows into something unwieldy. A waitlist with two inputs is trivial. But when a business requirement demands ten fields spread across five sections, hand-writing fifty inputs becomes an exercise in repetition that violates the DRY principle and wastes valuable development time.

The solution lies in Vue's core building blocks — components, the v-model directive, and props. Used together, they allow you to construct complex forms from small, reusable pieces without drowning in boilerplate.

Understanding Two-Way Binding With v-model

Vue ships with a set of special HTML attributes called directives, all prefixed with v-. Among them, v-model handles the critical job of two-way data binding, syncing a form input's value with a property in the component's data.

While v-model works across all input elements — from input to select — it operates differently depending on the element type underneath. For text inputs, it combines the input value with an input event listener:

<!-- Input element -->
<input v-model="inputValue" type="text">

<!-- Select element -->
<select v-model="selectedValue">
  <option value="">Please select the right option</option>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>

For <select>, <input type="checkbox">, and <input type="radio"> elements, v-model pairs the value with a change event instead.

Why Components Matter for Forms

Reusability stands as a core software engineering principle, and Vue components are the primary means of achieving it. A Vue component is a modular, self-contained interface with its own logic and template. Components can be nested like regular HTML elements or used in complete isolation.

There are two distinct ways to build a Vue component. The first skips the build step entirely, defining a component as a JavaScript object within the Vue instance's options property. In this approach, you embed a JavaScript string that Vue parses at runtime:

template: `
  <p> Vue component without the build step </p>
  `

The second approach leverages a build tool like Vite, producing what is known as a Single File Component (SFC). An SFC consolidates the file's logic, template, and styling into one cohesive unit:

<template>
  <p> Vue component with the build step </p>
</template>

In this SFC, the <p> tag inside the HTML <template> gets rendered when the application runs with a build step.

Registration: Making Components Available

Creating a component is only half the battle; you must also register it before use. Components support nesting — a global parent component can host child components, which in turn can contain their own children.

Suppose your build-step component lives in a BuildStep.vue file. To use it, import it into another .vue file — such as the root App.vue — and register its name in the components option property. Once registered, the component becomes available as a custom HTML tag. Vue's engine parses these custom tags as valid HTML and renders them in the browser:

<!-- App.vue -->
<template>
  <div>
    <BuildStep />
  </div>
</template>

<script>
import BuildStep from './BuildStep.vue'

export default {
  components: {
    BuildStep
  }
}
</script>

The example above shows BuildStep.vue imported into App.vue, registered in the components option, and then declared inside the HTML template as <BuildStep />.

Passing Data Down With Props

Props — short for properties — are custom attributes on a component that shuttle data from a parent component down to its children. They shine in scenarios where you need a consistent visual layout but varying content. A single component can accept as many props as your use case requires.

Critically, props enforce a one-way data flow: the parent owns the data, and the child receives it read-only. The child cannot mutate the parent's data directly; instead, it emits events that the parent listens for.

Declaring Props With Type Safety

To accept props, you add a props option to the component and define both the prop's name and its expected type:

<template>
  <p> Vue component {{ buildType }} the build step</p>
</template>

<script>
export default {
  props: {
    buildType: {
      type: String
    }
  }
}
</script>

In the updated template, the interpolated buildType expression will be evaluated and replaced with whatever value the parent passes down. The props option listens for prop changes and updates the template accordingly. The declared type can be a String, Number, Array, Boolean, or Object, acting as a validation rule. If you declare a type of String, passing a Boolean or Object will trigger an error.

Supplying Values From the Parent

To complete the data flow, update the parent file — App.vue — and pass the relevant props:

<!-- App.vue -->
<template>
  <div>
    <BuildStep buildType="with"/>
  </div>
</template>

<script>
import BuildStep from './BuildStep.vue'

export default {
  components: {
    BuildStep
  }
}
</script>

When the build step component renders, it will display the passed value:

Vue component with the build step

With props in place, there is no need to create a separate component every time you need to display a different build type. Simply re-declare the <BuildStep /> component with the new value:

<!-- App..vue -->
<template>
  <div>
    <BuildStep buildType="without"/>
  </div>
</template>

The rendered result follows the same pattern:

Vue component without the build step

Handling User Interaction

Beyond data binding, Vue provides the v-on directive for listening to and handling DOM events. For brevity, v-on can be abbreviated with the @ symbol:

<button @click="checkBuildType"> Check build type </button>

In the code above, the button element has a click event wired to a checkBuildType method. When clicked, it executes the function that inspects the component's build type.

Event Modifiers

v-on supports several event modifiers that attach extra behaviors to the event handler. Modifiers follow the event name with a dot:

<form @submit.prevent="submitData">
 ...
<!-- This enables a form to be submitted while preventing the page from being reloaded. -->
</form>

Key Modifiers for Keyboard Input

Key modifiers allow you to react to specific keyboard events, such as enter or page-up. They attach to the v-on directive in the form v-on:eventname.keymodifiername, where eventname might be keyup and modifiername could be enter:

<input @keyup.enter="checkInput">

Key modifiers are straightforward but also permit chaining multiple key names:

<input @keyup.ctrl.enter="checkInput">

In this example, both the ctrl and enter keyboard events must be detected before the checkInput method executes.

Rendering Lists With v-for

Vue's v-for directive provides the same iteration capabilities as JavaScript's native loops. The syntax mirrors ordinary array traversal: write item in items where items is the array being iterated, or use item of items for closer alignment with JavaScript loop syntax.

Basic List Display

To render a collection of build-step types, pass the array into the component's data property and iterate over it directly in the template:

<template>
  <div>
    <ul>
        <li v-for="steps in buildSteps" :key="steps.id"> {{ steps.step }}</li>
      </ul>
  </div>
</template>

<script>
export default {
 data() {
   return {
     buildSteps: [
      {
       id: "step 1",
       step:'With the build step',
      },
      {
        id: "step 2",
       step:'Without the build step'
      }
    ]
   }
 }
}
</script>

Here the steps array holds the two component build-step definitions. The template applies v-for to loop through that array, emitting each item inside a list element. An optional key attribute can carry either the current index or a unique identifier; the latter lets Vue track each node for reliable state management when the list changes.

Iterating Components

The same directive can generate component instances rather than plain elements:

<BuildStep v-for="steps in buildSteps" :key="steps.id"/>

Simply placing v-for on a component tag doesn't transfer the iterated value, though. The item must be explicitly passed as a prop:

<BuildStep v-for="steps in buildSteps" :key="steps.id" :buildType="steps.step" />

This explicit prop passing keeps the component decoupled from the directive, preserving the component's reusability. The real payoff is the automation: instead of hand-writing a hundred list entries or component instances, one v-for renders them all.

A Reusable Registration Form

Competing form-building approaches typically boil down to either static markup or generated fields. The data-driven method shown here uses v-model, custom components, props, and v-for together to produce a scalable form for capturing student biographical data.

Scaffolding The Project

Building the app requires Node.js plus either npm or yarn installed. Create the project with:

# npm
npm init vue@latest vue-complex-form

The command targets a Vue application named vue-complex-form. Inside the project root, start the development server with:

npm install

Defining Fields With JSON

Rather than hard-coding every input in the template, the form schema lives in a dedicated JSON file at util/bio-data.json. Each object defines one input's complete configuration:

[
  {
    "id": 1,
    "inputvalue":"  ",
    "formdata": "First Name",
    "type": "text",
    "inputdata": "firstname"
  },
  {
    "id": 2,
    "inputvalue":"  ",
    "formdata": "Last Name",
    "type": "text",
    "inputdata": "lastname"
  },
]

The structure separates concerns within each field object:

  • id is the unique identifier for the object;
  • inputvalue stores the value bound through v-model;
  • formdata carries the placeholder text and label;
  • type determines the input type, such as email, number, or text;
  • inputdata supplies the input's id and name attributes.

These values later flow into the reusable component as props.

Building The Input Component

Create components/TheInputTemplate.vue to define a single, reusable input that consumes the JSON schema:

<template>
  <div>
    <label :for="inputData">{{ formData }}</label>
    <input
      :value= "modelValue"
      :type= "type"
      :id= "inputData"
      :name= "inputData"
      :placeholder= "formData"
      @input="$emit('update:modelValue', $event.target.value)"
    >
  </div>
 </template>
 
<script>
export default {
  name: 'TheInputTemplate',
  props: {
    modelValue: {
      type: String
    },
    formData: {
      type: String
    },
    type: {
      type: String
    },
    inputData: {
      type: String
    }
  },
  emits: ['update:modelValue']
}
</script>
<style>
label {
  display: inline-block;
  margin-bottom: 0.5rem;
  text-transform: uppercase;
  color: rgb(61, 59, 59);
  font-weight: 700;
  font-size: 0.8rem;
}
input {
  display: block;
  width: 90%;
  padding: 0.5rem;
  margin: 0 auto 1.5rem auto;
}
</style>

This block accomplishes several goals:

  • Defines a component containing one input element;
  • Maps the incoming JSON values to the appropriate attribute slots;
  • Declares modelValue, formData, type, and inputData props that pull data from the parent component;
  • Binds the modelValue prop directly to the input's value;
  • Uses the compound update:modelValue event to propagate changes when the user types.

Registering And Feeding Data

Switch to App.vue, import the template component, and register it within the parent's components option so it can appear in the HTML template:

<template>
  <form class="wrapper">
    <TheInputTemplate/>
  </form>
</template>
<script>
import TheInputTemplate from './components/TheInputTemplate.vue'
export default {
  name: 'App',
  components: {
    TheInputTemplate
  }
}
</script>
<style>
html, body{
  background-color: grey;
  height: 100%;
  min-height: 100vh;
}
.wrapper {
  background-color: white;
  width: 50%;
  border-radius: 3px;
  padding: 2rem  1.5rem;
  margin: 2rem auto;
}
</style>

Running npm run serve at this stage shows an empty field set because no props have been wired yet:

Application interface just after rendering the input component and registering the component in the App.vue file
Input component after registration. (Large preview)

To populate the form, wire the JSON data through to the child component:

<template>
  <div class="wrapper">
    <div v-for="bioinfo in biodata" :key="bioinfo.id">
      <TheInputTemplate v-model="bioinfo.inputvalue":formData= "bioinfo.formdata":type= "bioinfo.type":inputData= "bioinfo.inputdata"/>
    </div>
  </div>
<script>
//add imports here
import biodata from "../util/bio-data.json";
export default {
  name: 'App',
 //component goes here
  data: () => ({
    biodata
  })
}
</script>

That update accomplishes three steps: the bio-data JSON file is imported and registered in the parent's data option; a v-for loop iterates over that imported data; and each field object passes its relevant values into the corresponding TheInputTemplate.vue props. The viewport should now render every form field defined in the schema:

Interface showing the rendered form after passing the props to the input component
Application view showing the rendered complex form. (Large preview)

With the Vue DevTools extension installed from https://devtools.vuejs.org, typing into any of the fields reveals the live value under modelValue in the DevTools inspector:

Vue Devtools view showing the modelValue of the input value
Vue DevTools showing the input value. (Large preview)

Extending the form later merely requires adding another object to the JSON schema — no additional template markup or component wiring is necessary. New team members can understand the entire form's structure from the data file instead of wading through extensive template code. For more advanced event handling across components, the official Vue documentation on component events covers pairing custom events with v-model.