DEV Community

Cover image for Watching for Changes in Vue.js Component Slot Content
Austin Gil
Austin Gil

Posted on • Originally published at austingil.com on

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
qq449245884 profile image
qq449245884

Dear Austin Gil,may I translate your all dev articles into Chinese?I would like to share it with more developers in China. I will give the original author and original source.

Collapse
 
austingil profile image
Austin Gil

Hey, sorry for the late reply, but yes. Please just link back to the original articles. Let me know when you're done :)