From Vue 2 to Vue 3: The New $attrs

·

7 min read

Welcome back!

In this lesson we’re going to explore the changes that Vue 3 brings to the $attrs of a component.

Understanding these changes is fundamental to the creation of any type of custom components in Vue 3, as the new $attrs not only contains your HTML attributes like it did before, but now also your listeners, classes, and styles.

By learning these new concepts, you will be able to understand how attributes are passed down to ANY component in Vue 3, as well as how to correctly bind events from a parent to a child component.

Differences between Vue 2 and Vue 3 in $attrs

In Vue 2, $attrs is a property of a component’s data that includes all of the attributes that the parent passes into a component, with the exception of class, and style. These two in particular reside at the same level as $attrs in the data object of a component instance.

Also in Vue 2, we had a $listeners object, which included all of the functions that would act as listeners when the child emitted an event.

So what exactly can we find inside $attrs now in Vue 3?

In Vue 3, we no longer have the $listeners part of the data object. This object has been fully merged into the $attrs object, with a few key differences.

Listeners are no longer listed as the exact keyword, such as click, or input like they were in Vue 2 under $listeners.

// Vue 2
<MyButton 
  @click="handleClick" 
  @custom="handleCustom" 
  v-model="value"
  type="button"
  class="btn"
/>
// Inside MyButton.vue
$listeners = {
  click: handleClick,
  custom: handleCustom,
  input: () => {}
}

$attrs = {
  type: 'button'
}

Instead we can find our listeners with their onEvent format, just like in vanilla JavaScript.

// Vue 3
<MyButton 
  @click="handleClick" 
  @custom="handleCustom"
  v-model="value" 
  type="button"
  class="btn"
/>
// Inside MyButton.vue
$attrs = {
  class: 'btn'
  type: 'button',
  onClick: handleClick,
  onCustom: handleCustom,
  'onUpdate:modelValue': () => { value = payload },
}

As you can see, our click listener can now be found inside $attrs as onClick. A custom event would be onCustom, and even our v-model now shows onUpdate:modelValue with the default bindings that we learned on lesson 2 in this course.

Inside $attrs, you may also find all the attributes that a component receives from the parent, such as id, aria attributes, data attributes and other HTML attributes like col, row, type and src. In the case of this example button, it will receive the type attribute that our parent injected into it.

It is important to remember that in Vue 3, class and style are also part of the $attrs object.

Binding $attrs to a component

In order to better learn how to correctly use Vue 3 $attrs in a component, we’re going to build upon a plain input wrapper component. I’ve gone ahead and created the starting stub code, so that we can dive right into it.

The component includes both a label and an input element — they are both wrapped up inside a parent div as we usually would do in a Vue 2 application. Don’t worry, we’ll cover how to remove this unnecessary div with multiroot component in the next lesson.

The label is bound to a label property, and the input element is v-model capable with the defaults: modelValue as a property, and update:modelValue as the emitted event.

📃BaseInput.vue

<template>
  <div>
    <label>{{ label }}</label>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    />
  </div>
</template>

<script>
export default {
  props: {
    modelValue: {
      type: [String, Number],
      default: ''
    },
    label: {
      type: String,
      default: null
    }
  }
}
</script>

We can now use the BaseInput component directly in App.vue by importing it and adding it to the template.

📃App.vue

<template>
  <div id="app">
    <BaseInput
      v-model="email"
      label="Email:"
    />

    <pre>{{ email }}</pre>
  </div>
</template>

<script>
import { ref } from 'vue'
import BaseInput from './components/BaseInput'

export default {
  name: 'App',
  components: {
    BaseInput
  },
  setup () {
    const email = ref('')

    return {
      email
    }
  }
}
</script>

Now that we have a component to play with, let’s take a look at what happens when we try to apply a type declaration to our component instance in App.vue in order to change this input to a type of email.

📃App.vue

<BaseInput
  v-model="email"
  label="Email:"
  type="email"
/>

image.png

As expected, we get the same behaviour as in Vue 2. The type declaration has been set into the root div tag of the component.

If we now add a class```` declaration to ourBaseInputinstance insideApp.vue```, we will also get the same behaviour.

📃App.vue

<BaseInput
  v-model="email"
  label="Email:"
  type="email"
  class="thicc"
/>

image.png

Let’s go back to BaseInput.vue and bind the $attrs correctly into our input element.

📃BaseInput.vue

<template>
  <div>
    <label>{{ label }}</label>
    <input
      v-bind="$attrs"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    />

    <pre>{{ $attrs }}</pre>
  </div>
</template>

Now if we check the browser, we’ll notice an interesting thing.

image.png

The input element is now correctly binding our type, but also the class! Although this may not seem very exciting at face value, this is actually a huge improvement in Vue 3 over Vue 2.

In Vue 2, not only were the class and style tags not declared inside $attrs — they were always fixed into the root of the component, in this case, our div. In Vue 3 we can use this ability to bind classes into components without having to resort to less-than-ideal solutions like creating a classes property.

Did you notice? Our class and type are still bound to the wrapping div as well —inheritAttrs: false is still a thing.

We need to add this to our component’s object in order to let Vue 3 know that we are going to take care of binding everything ourselves, so let’s go ahead and add it to our component.

📃BaseInput.vue

export default {
  inheritAttrs: false,
  props: {
    modelValue: {
      type: [String, Number],
      default: ''
    },
    label: {
      type: String,
      default: null
    }
  }
}

If we check the browser one more time, now the div will not contain the bindings that we’re setting for our input.

image.png

Binding listeners through our $attrs

Now that we have our $attr binding set up, we can take a look at how to bind listeners into our component.

We currently have our component set up to listen directly to input events through the @input binding. But sometimes we want to allow our component to respond to any type of event that the parent wants to listen to, especially in the case of binding into native input elements.

For example, let’s go back to App.vue and add an event listener for blur into our BaseInput instance that will change the value of ```email```` whenever the user blurs or tabs out of the input field.

📃App.vue

<BaseInput
  v-model="email"
  @blur="email = 'blurrr@its.cold'"
  label="Email:"
  type="email"
  class="thicc"
/>

Let’s go back to the browser and test it out by blurring out of the input element.

image.png

If you had a lot of Vue 2 experience at this point you may be wondering, wait—how?

In Vue 2 we had to add v-on="$listeners" into our input element to tell Vue that we wanted to delegate all the events (other than the ones that have been manually declared and therefore overridden) to our parent. This would allow our parent, App.vue in this case, to handle declaring the logic for when these events happened—like we just did with the blur listener.

In Vue 3, this is no longer necessary. The moment we added our v-bind="$attrs" declaration we also added a binding for all the listeners that the parent passed into our component.

Remember, $attrs now also includes all the listeners, so in this example it will contain an onBlur declaration that holds the function that sets email to blurrr@its.cold

How convenient is that? 😎

Last minute cleanup

If you’re like me and you like to keep your v-bind clean and contained, let me tell you that there is a nice way to refactor the @input listener into our v-bind declaration using the JavaScript spread operator.

We currently have our template inside BaseInput set like this.

📃BaseInput.vue

<input
  v-bind="$attrs"
  @input="$emit('update:modelValue', $event.target.value)"
  :value="modelValue"
/>

Since $attrs is an object, we can safely use the spread operator to wrap it inside an object. This way, we can now declare the input listener as onInput inside our v-bind declaration to keep all of our internal listener’s logic inside of it.

📃BaseInput.vue

<input
  v-bind="{
    ...$attrs,
    onInput: (event) => $emit('update:modelValue', event.target.value)
  }"
  :value="modelValue"
/>

Not only is this clearer to look at, but it’s also a very good tool to make sure that certain events like input don’t get overwritten by the parent.

Wrapping up

In this lesson we learned how to correctly bind our $attrs object in Vue 3, and the differences that it has with Vue 2. We also learned how to bind our listeners now that the $listeners object has been merged into $attrs.

In our next lesson, we will take a deeper look into single vs multiple root components and what role $attrs plays when migrating from single root to multi root.

See you there!