avatarAustin Gil

Summary

This article discusses a method for updating the state of a Vue.js component whenever its contents change, specifically in the context of a form component that tracks the validity state of its inputs.

Abstract

The article begins by explaining the need to update a component's state when its contents change, specifically in the context of a form component that tracks input validity. The author initially thought this would be straightforward but found limited resources on the topic. The solution involves using the "input" event and event delegation to capture changes within the component's slot content. The form's validity is then checked using the native form.checkValidity() API. However, a problem arises when inputs are added to the DOM after the form has mounted, which is solved by using the MutationObserver API to watch for changes to the DOM node's content. The article concludes by providing a complete example of the form component with a scoped slot that provides the form's validity state to the parent component.

Bullet points

  • The author needed to update a component's state whenever its contents changed, specifically for a form component tracking input validity.
  • The initial approach involved using the "input" event and event delegation to capture changes within the component's slot content.
  • The form's validity was checked using the native form.checkValidity() API.
  • A problem arose when inputs were added to the DOM after the form had mounted.
  • The solution to this problem involved using the MutationObserver API to watch for changes to the DOM node's content.
  • The article provides a complete example of the form component with a scoped slot that provides the form's validity state to the parent component.

Watching for Changes in Vue.js Component Slot Content

I recently had the need to update the state of a component any time its contents (slot, children, etc.) changed. For context, it’s a form component that tracks the validity state of its inputs.

I thought it would be more straight forward than it was, and I didn’t find a whole lot of content out there. So having a solution I’m satisfied with, I decided to share. Let’s build it out together :)

The following code snippets are written in the Options API format but should work with Vue.js version 2 and version 3 except where specified.

The Setup

Let’s start with a form that tracks its validity state, modifies a class based on the state, and renders it’s children as a <slot/>.

<template>
  <form :class="{ '--invalid': isInvalid }">
    <slot />
  </form>
</template>
<script>
export default {
  data: () => ({
    isInvalid: false,
  }),
};
</script>

To update the isInvalid property, we need to attach an event handler to some event. We could use the “submit” event, but I prefer the “input” event.

Form’s don’t trigger an “input” event, but we can use a pattern called “event delegation“. We’ll attach the listener to the parent element (<form>) that gets triggered any time the event occurs on it’s children (<input>, <select>, <textarea>, etc).

Any time an “input” event occurs within this component’s <slot> content, the form will capture the event.

<template>
  <form :class="{ '--invalid': isInvalid }" @input="validate">
    <slot />
  </form>
</template>
<script>
export default {
  data: () => ({
    isInvalid: false,
  }),
  methods: {
    validate() {
      // validation logic
    }
  }
};
</script>

The validation logic can be as simple or complex as you like. In my case, I want to keep the noise down, so I’ll use the native form.checkValidity() API to see if the form is valid based on HTML validation attributes.

For that, I need access to the <form> element. Vue makes it easy through “refs” or with the $el property. For simplicity, I’ll use $el.

<template>
  <form :class="{ '--invalid': isInvalid }" @input="validate">
    <slot />
  </form>
</template>
<script>
export default {
  data: () => ({
    isInvalid: false,
  }),
  methods: {
    validate() {
      this.isInvalid = !this.$el.checkValidity()
    }
  }
};
</script>

This works pretty well. When the component mounts onto the page, Vue will attach the event listener, and on any input event it will update the form’s validity state. We could even trigger the validate() method from within the mounted lifecycle event to see if the form is invalid at the moment it mounts.

The Problem

We have a bit of an issue here. What happens if the contents of the form change? What happens if an <input> is added to the DOM after the form has mounted?

As an example, let’s call our form component “MyForm”, and inside of a different component called “App”, we implement “MyForm”. “App” could render some inputs inside the “MyForm” slot content.

<template>
  <MyForm>
    <input v-model="showInput" id="toggle-name" name="toggle-name" type="checkbox">
    <label for="toggle-name">Include name?</label>
    <template v-if="showInput">
      <label for="name">Name:</label>
      <input id="name" name="name" required>
    </template>
    <button type="submit">Submit</button>
  </MyForm>
</template>
<script>
export default {
  data: () => ({
    showInput: false
  }),
}
</script>

If “App” implements conditional logic to render some of the inputs, our form needs to know. In that case, we probably want to track the validity of the form any time its content changes, not just on “input” events or mounted lifecycle hooks. Otherwise, we might display incorrect info.

The Solution

After a bit of research and testing, the best solution I’ve come up with is to use the MutationObserver API. This API is built into the browser and allows us to essentially watch for changes to a DOM node’s content. One cool benefit here is that it’s framework agnostic.

What we need to do is create a new MutationObserver instance when our component mounts. The MutationObserver constructor needs the callback function to call when changes occur, and the MutationObserver instance needs the element to watch for changes on, and a settings object.

<script>
export default {
  // other code
  mounted() {
    const observer = new MutationObserver(this.validate);
    observer.observe(this.$el, {
      childList: true,
      subtree: true 
    });
    this.observer = observer;
  },
  // For Vue.js v2 use beforeDestroy
  beforeUnmount() {
    this.observer.disconnect();
  }
  // other code
};
</script>

Note that we also tap into the beforeUnmount (for Vue.js v2, use beforeDestroy) lifecycle event to disconnect our observer, which should clear up any memory it has allocated.

Most of the parts are in place, but there is just one more thing I want to add. Let’s pass the isInvalid state to the slot for the content to have access to. This is called a “scoped slot” and it’s incredibly useful.

With that, our completed component could look like this:

<template>
  <form :class="{ '--invalid': isInvalid }">
    <slot v-bind="{ isInvalid }" />
  </form>
</template>
<script>
export default {
  data: () => ({
    isInvalid: false,
  }),
  mounted() {
    this.validate();
    const observer = new MutationObserver(this.validate);
    observer.observe(this.$el, {
      childList: true,
      subtree: true 
    });
    this.observer = observer;
  },
  beforeUnmount() {
    this.observer.disconnect();
  }
  methods: {
    validate() {
      this.isInvalid = !this.$el.checkValidity()  
    },
  },
};
</script>

With this setup, a parent component can add any number of inputs within our form component and add whatever conditional rendering logic it needs. As long as the inputs use HTML validation attributes, the form will track whether or not it is in a valid state.

Furthermore, because we are using scoped slots, we are providing the state of the form to the parent, so the parent can react to changes in validity.

For example, if our component was called <MyForm> and we wanted to “disable” the submit button when the form is invalid, it might look like this:

<template>
  <MyForm>
    <template slot:default="form">
      <label for="name">Name:</label>
      <input id="name" name="name" required>
      <button
        type="submit"
        :class="{ disabled: form.invalid }"
      >
        Submit
      </button>
    </template>
  </MyForm>
</template>

Note that I don’t use the disabled attribute to disable the button because some folks like Chris Ferdinandi and Scott O’Hara believe it’s an accessibility anti-pattern (more on that here).

Makes sense to me. Do what makes sense to you.

The Recap

This was an interesting problem to face and was inspired by work on Vuetensils. For a more robust form solution, please take a look a that library’s VForm component. I like it.

Any time I can use native browser features feels good because I know the code will be reusable in any project or code base I run into in the future. Even if my framework changes.

If you liked this article, please share it. Got feedback or recommendations? Hit me up on Twitter. And if you want to know when I publish more articles like this, sign up for my newsletter. Cheers!

Vuejs
Front End Development
Web Development
Software Development
JavaScript
Recommended from ReadMedium