Watching for Changes in Vue.js Component Slot Content

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.

If you are familiar with Vue.js lifecycle hooks, you may be thinking at this point that we could simply use the updated to track changes. In theory, this sounds good. In practice, it can create an infinite loop and crash the browser.

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 = obsesrver
  },
  // 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 = obsesrver
  },
  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:defaul="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.

Thank you so much for reading. If you liked this article, please share it, and if you want to know when I publish more articles, sign up for my newsletter or follow me on Twitter. Cheers!


Originally published on austingil.com.

Did you find this article valuable?

Support Austin Gil by becoming a sponsor. Any amount is appreciated!