DEV Community

Cover image for How to Create Data-Driven User Interfaces in Vue
Evan Schultz (he/him)
Evan Schultz (he/him)

Posted on

How to Create Data-Driven User Interfaces in Vue

Although we usually know what components are needed when building most views within an app, sometimes we don't know what they are until runtime.

This means we need to build a screen based on the application state, user preferences, or response from an API. A common case is creating dynamic forms, where the questions and components needed are either configured by a JSON object, or where fields change based on the users' answers.

All modern JavaScript frameworks have ways of handling dynamic components. This blog post will show you how to do it in Vue.JS, which provides a remarkably elegant and easy solution to the above scenario.

Once you see how easy this can be using Vue.JS, you might get inspired and see applications for dynamic components that you have never considered before!

We need to walk before we can run, so first I'll go over the basics of dynamic components, and then dive into how these concepts can be used to build your own dynamic form builder.

The Basics

Vue has a built-in component called (fittingly) <component>. You can see the full details in the VueJS guide on Dynamic Components.

The guide says:

"You can use the same mount point and dynamically switch between multiple components using the reserved element and dynamically bind to its is attribute."

What this means is that being able to swap between components can be as simple as:

<component :is="componentType">

Let's flesh this out a bit more and see what is going on. We will create two components called DynamicOne and DynamicTwo - for now One and Two will be the same, so I won’t repeat the code for both:

<template>
  <div>Dynamic Component One</div>
</template>
<script>
export default {
  name: 'DynamicOne',
}
</script>

For a quick example of being able to toggle between them, in our App.vue we will set up our component:

import DynamicOne from './components/DynamicOne.vue'
import DynamicTwo from './components/DynamicTwo.vue'

export default {
  name: 'app',
  components: {
    DynamicOne, DynamicTwo
  },
  data() {
    return {
      showWhich: 'DynamicOne'
    }
  }
}

Note: the showWhich data property is the string value of DynamicOne - this is the property name created in the components object on the component.

In our template, we will set up two buttons to swap between the two dynamic components:

<button @click="showWhich = 'DynamicOne'">Show Component One</button>
<button @click="showWhich = 'DynamicTwo'">Show Component Two</button>

<component :is="showWhich"></component>

Clicking on the buttons will swap out DynamicOne with DynamicTwo.

At this point, you might be thinking, “Well, so what? That’s handy - but I could have used v-if just as easily.”

This example starts to shine when you realize that <component> works just like any other component, and it can be used in combination with directives like v-for for iterating over a collection, or making the :is bindable to an input prop, data prop, or computed property.

What about the props and events?

Components don’t live in isolation - they need a way to communicate with the world around them. With Vue, this is done with props and events.

You can specify property and event bindings on a dynamic component the same way as any other component, and if the component that gets loaded does not need that property, Vue will not complain about unknown attributes or properties.

Let's modify our components to display a greeting. One will accept just firstName and lastName, while another will accept firstName, lastName and title.

For the events, we will add a button in DynamicOne that will emit an event called ‘upperCase’, and in DynamicTwo, a button that will emit an event ‘lowerCase’.

Putting it together, consuming the dynamic component starts to look like:

<component 
    :is="showWhich" 
    :firstName="person.firstName"
    :lastName="person.lastName"
    :title="person.title"
    @upperCase="switchCase('upperCase')"
    @lowerCase="switchCase('lowerCase')">
</component>

Not every property or event needs to be defined on the dynamic component that we are switching between.

Do you need to know all the props upfront?

At this point, you might be wondering, "If the components are dynamic, and not every component needs to know every possible prop - do I need to know the props upfront, and declare them in the template?"

Thankfully, the answer is no. Vue provides a shortcut, where you can bind all the keys of an object to props of the component using v-bind.

This simplifies the template to:

<component 
    :is="showWhich" 
    v-bind="person"
    @upperCase="switchCase('upperCase')"
    @lowerCase="switchCase('lowerCase')">
</component>

What about Forms?

Now that we have the building blocks of Dynamic Components, we can start building on top of other Vue basics to start building a form generator.

Let's start with a basic form schema - a JSON object that describes the fields, labels, options, etc for a form. To start, we will account for:

  • Text and Numeric input fields
  • A Select list

The starting schema looks like:

schema: [{
    fieldType: "SelectList",
    name: "title",
    multi: false,
    label: "Title",
    options: ["Ms", "Mr", "Mx", "Dr", "Madam", "Lord"],
  },
  {
    fieldType: "TextInput",
    placeholder: "First Name",
    label: "First Name",
    name: "firstName",
  },
  {
    fieldType: "TextInput",
    placeholder: "Last Name",
    label: "Last Name",
    name: "lastName",
  },
  {
    fieldType: "NumberInput",
    placeholder: "Age",
    name: "age",
    label: "Age",
    minValue: 0,
  },
]

Pretty straightforward - labels, placeholders, etc - and for a select list, a list of possible options.

We will keep the component implementation for these simple for this example.

TextInput.vue

<template>
<div>
    <label>{{label}}</label>
    <input type="text"
         :name="name"
          placeholder="placeholder">
</div>
</template>
<script>
export default {
  name: 'TextInput',
  props: ['placeholder', 'label', 'name']
}
</script>

SelectList.vue

<template>
  <div>
    <label>{{label}}</label>
    <select :multiple="multi">
      <option v-for="option in options"
              :key="option">
        {{option}}
      </option>
    </select>
  </div>
</template>
<script>
export default {
  name: 'SelectList',
  props: ['multi', 'options', 'name', 'label']
}
</script>

To generate the form based on this schema, add this:

<component v-for="(field, index) in schema"
  :key="index"
  :is="field.fieldType"
  v-bind="field">
</component>

Which results in this form:

Data Binding

If a form is generated but does not bind data, is it very useful? Probably not. We currently are generating a form but have no means of binding data to it.

Your first instinct might be to add a value property to the schema, and in the components use v-model like so:

<input type="text" 
    :name="name"
    v-model="value"
    :placeholder="placeholder">

There are a few potential pitfalls with this approach, but the one that we care about most is one that Vue will give us an error/warning about:

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"

found in

---> <TextInput> at src/components/v4/TextInput.vue
       <FormsDemo> at src/components/DemoFour.vue
         <App> at src/App.vue
           <Root>

While Vue does provide helpers to make the two-way binding of component state easier, the framework still uses a one-way data flow. We have tried to mutate the parent's data directly within our component, so Vue is warning us about that.

Looking a little more closely at v-model, it does not have that much magic to it, so let's break it down as described in the [Vue Guide on Form Input Components](https://vuejs.org/v2/guide/components-custom-events.html#Binding-Native-Events-to-Components_.

<input v-model="something">

Is similar to:

<input
  v-bind:value="something"
  v-on:input="something = $event.target.value">

With the magic revealed, what we want to accomplish is:

  • Let the parent provide the value to the child component
  • Let the parent know that a value has been updated

We accomplish this by binding to the :value and emitting an @input event to notify the parent that something has changed.

Let's look at our TextInput component:

 <div>
  <label>{{ label }}</label>
  <input
    type="text"
    :name="name"
    :value="value"
    @input="$emit('input',$event.target.value)"
    :placeholder="placeholder"
  />
</div>

Since the parent is responsible for providing the value, it is also responsible for handling the binding to its own component state. For this we can use v-model on the component tag:

FormGenerator.vue

<component v-for="(field, index) in schema"
    :key="index"
    :is="field.fieldType"
    v-model="formData[field.name]"
    v-bind="field">
</component>

Notice how we are using v-model="formData[field.name]". We need to provide an object on the data property for this:

export default {
  data() {
    return {
      formData: {
        firstName: 'Evan'
      },
}

We can leave the object empty, or if we have some initial field values that we want to set up, we could specify them here.

Now that we have gone over generating a form, it’s starting to become apparent that this component is taking on quite a bit of responsibility.

While this is not complicated code, it would be nice if the form generator itself was a reusable component.

Making the Generator Reusable

For this form generator, we will want to pass the schema to it as a prop and be able to have data-binding set up between the components.

When using the generator, the template becomes:

GeneratorDemo.vue

<form-generator :schema="schema" v-model="formData">
</form-generator>

This cleans up the parent component quite a bit. It only cares about FormGenerator, and not about each input type that could be used, wiring up events, etc.

Next, make a component called FormGenerator. This will pretty much be copy-pasted of the initial code with a few minor, but key tweaks:

  • Change from v-model to :value and @input event handling
  • Add props value and schema
  • Implement updateForm

The FormGenerator component becomes:

FormGenerator.vue

<template>
  <component v-for="(field, index) in schema"
             :key="index"
             :is="field.fieldType"
             :value="formData[field.name]"
             @input="updateForm(field.name, $event)"
             v-bind="field">
    </component>
</template>
<script>
import NumberInput from '@/components/v5/NumberInput'
import SelectList from '@/components/v5/SelectList'
import TextInput from '@/components/v5/TextInput'

export default {
  name: "FormGenerator",
  components: { NumberInput, SelectList, TextInput },
  props: ['schema', 'value'],
  data() {
    return {
      formData: this.value || {}
    };
  },
  methods: {
    updateForm(fieldName, value) {
      this.$set(this.formData, fieldName, value);
      this.$emit('input', this.formData)
    }
  }
};
</script>

Since the formData property does not know every possible field that we could pass in, we want to use this.$set so Vue's reactive system can keep track of any changes, and allow the FormGenerator component to keep track of its own internal state.

Now we have a basic, reusable form generator.

Using the Generator

<template>
  <form-generator :schema="schema" v-model="formData">
  </form-generator>
</template>
<script>
import FormGenerator from '@/components/v5/FormGenerator'

export default {
  name: "GeneratorDemo",
  components: { FormGenerator },
  data() {
    return {
      formData: {
        firstName: 'Evan'
      },
      schema: [{ /* .... */ },
}
</script>

So now that you've seen how a form generator can leverage the basics of dynamic components in Vue to create some highly dynamic, data-driven UIs,

I encourage you to play around with this example code on GitHub, or experiment on [CodeSandbox]. And feel free to reach out if you have any questions or want to talk shop, comment below or reach out on:

note: This was initially published on the rangle.io blog on March 7, 2018

Top comments (0)