DEV Community

Favour Afolayan
Favour Afolayan

Posted on

Build a simple and reusable Vue JS radio button component

In this article, I want to show you how to DRY up a piece of repeated code into pieces of reusable components.

App.vue

<template>
 <div>
  <UserDecision v-model="decision" />
 </div>
</template>

<script>
 import Vue from 'vue';

 import UserDecision from './UserDecision.vue';

 export default {
  components: { UserDecision },
  data: () => ({
   decision: '',
  })
 }

</script>
Enter fullscreen mode Exit fullscreen mode

UserDecision.vue


 <template>
    <div class="user-decision">
        <label for="Yes">
            <input type="radio" name="radio" value="Yes" id="Yes" 
             @change="$emit('input', 'Yes')" />
            Yes
        </label>
        <label for="No">
            <input type="radio" name="radio" value="No" id="No" 
            @change="$emit('input', 'No')" />
            No</label
        >
        <label for="Undecided">
            <input
                type="radio"is
                name="radio"
                value="Undecided"
                id="Undecided"
                @change="$emit('input', 'Undecided')"
            />Undecided
        </label>
    </div>
 </template>

<script>
 import Vue from 'vue';

 export default {} 
</script>

<style>
 .user-decision {
  width: 60%;
  margin: 20px auto;
  padding: 15px 20px;
  border: 1px solid black;
  border-radius: 8px;

  display: flex;
  justify-content: space-between;
}
</style>


Enter fullscreen mode Exit fullscreen mode

If you look at this code, this works fine if you don't think there would ever be the need for more implementations of such.

So for the purpose of reusability, I will show you how we can refactor this, to make it easy and simple to reuse.

The first level of refactoring I see we can do here is inside the UserDecison.vue component file. A careful look at that file will reveal that the input, label elements are repeated. Let's DRY it up.

BaseRadioButtonGroup.vue

 <template>
  <div class="radio-button-group">
   <label :for="option" v-for="option in options">
    <input type="radio" name="radio-input" :value="option" 
 :id="option" 
    @change="$emit('input', option)" />
    {{ option }}
   </label>
  </div>
 </template>

<script>
 import Vue from 'vue';

 export default {
  name: 'BaseRadioButtonGroup',
  props: {
    options: {
     required: true,
     type: Array
    }
  }
 }
</script>

<style>
 .radio-button-group {
  width: 60%;
  margin: 20px auto;
  padding: 15px 20px;
  border: 1px solid black;
  border-radius: 8px;

  display: flex;
  justify-content: space-between;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Now to use our new, usage-agnostic component we have renamed 'BaseRadioButtonGroup', let's go back to the App.vue file.

App.vue

<template>
 <div>
  <BaseRadioButtonGroup v-model="decision" :options="options" />
 </div>
</template>

<script>
 import Vue from 'vue';

 import BaseRadioButtonGroup from './BaseRadioButtonGroup.vue';

 export default {
  components: { UserDecision },
  data: () => ({
   decision: '',
   options: ['Yes', 'No', 'Undecided']
  })
 }

</script>
Enter fullscreen mode Exit fullscreen mode

What we have done so far is fine, but if you look at the BaseRadioButtonGroup component, we can even further break down its content into a smaller component that would give us more flexibility. Let me show you what I mean.

BaseRadioButtonGroup.vue

 <template>
 <div class="radio-button-group">
  <label :for="option" v-for="option in options" :key="option">
   <input type="radio" name="radio-input" :value="option" :id="option" 
    @change="$emit('input', option)" />
    {{ option }}
  </label>
 </div>
 </template>

<script>
 import Vue from 'vue';

 export default {
  name: 'BaseRadioButtonGroup',
  props: {
    options: {
     required: true,
     type: Array
    }
  }
 }
</script>

<style>
 .radio-button-group {
  width: 60%;
  margin: 20px auto;
  padding: 15px 20px;
  border: 1px solid black;
  border-radius: 8px;

  display: flex;
  justify-content: space-between;
}
</style>
Enter fullscreen mode Exit fullscreen mode

BaseRadioButton.vue

 <template>
  <label :for="option">
   <input type="radio" name="radio-input" :value="option" :id="option" 
    @change="$emit('input', option)" />
    {{ option }}
  </label>
 </div>
 </template>

<script>
 import Vue from 'vue';

 export default {
  name: 'BaseRadioButton',
  props: {
    option: {
     required: true,
     type: string
    }
  }
 }
</script>
Enter fullscreen mode Exit fullscreen mode

We have extracted this into a component that is decoupled, can be re-used, easily styled and customized, any time in the future.

Now, let's update the BaseRadioButtonGroup component to reflect the change we made.

BaseRadioButtonGroup.vue

 <template>
 <div class="radio-button-group">
  <BaseRadioButton v-for="option in options" :option="option" :key="option" @input="inputEventHandler" />
 </div>
 </template>

<script>
 import Vue from 'vue';

 import BaseRadioButton from './BaseRadioButton.vue';

 export default {
  name: 'BaseRadioButtonGroup',
  components: { BaseRadioButton },
  props: {
    options: {
     required: true,
     type: Array
    }
  },
  methods: {
   inputEventHandler(value) {
     this.$emit('input', value);
   }
  }
 }
</script>

<style>
 .radio-button-group {
  width: 60%;
  margin: 20px auto;
  padding: 15px 20px;
  border: 1px solid black;
  border-radius: 8px;

  display: flex;
  justify-content: space-between;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Now, because the event is two levels down, we had to handle it at each level of the component to ensure it is emitted to the parent component to ensure the v-model is kept in sync. i.e. the BaseRadioButton emits an input event, that the BaseRadioButtonGroup component listens to and finally emits to the v-model inside the App.vue file.

For the purpose of writing less code and getting the same behaviour, we can get rid of the method by emitting the input event straight to the App.vue file from down inside the BaseRadioButton component.

Let me quickly show you what I mean.

BaseRadioButton.vue

 <template>
  <label :for="option">
   <input type="radio" name="radio-input" :value="option" :id="option" 
    @change="$parent.$emit('input', option)" />
    {{ option }}
  </label>
 </div>
 </template>
Enter fullscreen mode Exit fullscreen mode

With the $parent.$emit property, the event will keep bubbling up the component tree until it gets inside the App.vue where the v-model property will listen to it.

Now, we can get rid of the method we created to emit the event inside the BaseRadioButtonGroup component.

BaseRadioButtonGroup.vue

 <template>
 <div class="radio-button-group">
  <BaseRadioButton v-for="option in options" :option="option" :key="option" />
 </div>
 </template>

<script>
 import Vue from 'vue';

 import BaseRadioButton from './BaseRadioButton.vue';

 export default {
  name: 'BaseRadioButtonGroup',
  components: { BaseRadioButton },
  props: {
    options: {
     required: true,
     type: Array
    }
  },
 }
</script>
Enter fullscreen mode Exit fullscreen mode

Now, that we have two components that can be used together or singly. To render N-number of radio buttons, all we need to do is to pass an array of the options to the BaseRadioButtonGroup component and it would work fine. And if we need more control over every single radio button, we can equally use the BaseRadioButton component.

You can play with the final code here

Discussion (2)

Collapse
peoray profile image
Emmanuel Raymond

Thank you so much for this wonderful tutorial. So clear and easy to follow.

Collapse
igbominadeveloper profile image
Favour Afolayan Author

You are welcome. Glad you found it useful