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 = 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.
Thank you so much for reading. If you liked this article, and want to support me, the best ways to do so are to share it, sign up for my newsletter, and follow me on Twitter.
Originally published on austingil.com.