Beware of Leaky Abstractions When Relying on Attribute Inheritance in Vue Components
Update: My Twitter friend Austin Gil pointed out that he disagrees with a couple of my statements in this article. And he took the time to write down his thoughts. I’m convinced this makes an interesting discussion, so I added his comments throughout the piece. Usually, we read blog articles like this written in a tone that makes it sound like the author’s opinion is the absolute truth. But more often than not, it’s not. Depending on your use case or which trade-offs you prefer, you can come to different equally right (or wrong) conclusions.
In this article, we will discuss the problems associated with attribute inheritance, also known as fallthrough attributes, in Vue components and why it might be best to avoid using them. Attribute inheritance can increase the API surface of components, making it challenging to maintain and refactor them in the future. Instead of relying on this feature, I recommend adhering to best practices for component design, such as encapsulation and the Interface Segregation Principle. By doing so, we can create more maintainable and robust components less prone to breaking changes. We will also compare Vue’s approach to attribute inheritance with React’s, which does not have this feature by default, and discuss the pros and cons of each approach.
The Problems with Attribute Inheritance
Attribute inheritance in Vue components can lead to several issues that make it difficult to maintain and refactor components. Here are the main problems associated with this feature:
Vastly bigger API surface area
When using attribute inheritance, every possible attribute of the root element becomes part of the component’s API surface. Changing the root element or its attributes may inadvertently result in breaking changes. The larger the API surface, the more parts of our codebase we must consider when making changes to a component that affects its public API.
Note from Austin Gil
I think you're conflating API design and underlying features of HTML, which is a dangerous assumption. Supporting HTML attributes does not grow your API, but it is the responsibility of component authors to understand what can be done with HTML. Removing that actually removes useful features. As a developer, it's very frutstrating when I cannot do something that would otherwise be supported with plain HTML (eg. title attribute, aria attributes, inert).
My reply
A component is a level of abstraction. The more implementation details an abstraction hides, the more powerful it can be. Attribute inheritance makes the root element part of the public contract of the component; thus, the abstraction less useful. Not everybody is aware of that. Yet I agree that inheritance can be convenient; conversely, taking it away can be annoying.
For example, if we change the root element from an <input>
to a <div>
, all instances of the component relying on input-specific attributes (e.g., pattern="[A-Za-z]{3}"
) will break.
Note from Austin Gil
This would justify a breaking change in your release and should be treated as such. It's not realistic to assume libraries WONT introduce breaking changes, so the responsibility lies on the author to communicate this, and the consumer to aknowledge/test.
My reply
This concept also applies to components in regular projects, not only ones provided by libraries. And especially for library authors, the component abstraction is more valuable the more details it hides. Swapping the root element wouldn't be a breaking change without attribute inheritance.
class
and style
merging
Vue also merges class
and style
attributes automatically. What seems like a handy feature can become a footgun if we do not use it wisely. class
merging makes it tempting to override component styles from the parent component using the class
attribute. Doing so can be incredibly alluring when using utility-based CSS frameworks like Tailwind CSS. When we change the styling of a component, we inadvertently create breaking changes for parent components that rely on the original styles (e.g., <BaseCard class="justify-content" />
will break if we remove flex
from the list of classes of the root element of BaseCard
).
Note from Austin Gil
Developers should have access to interact directly with the underlying HTML if/when they know better. By avoiding fallthrough props, you remove the ability for developers to do so. It's a convenience that allows a parent to modify the class/style of the child, but removing class merging only removes the convenience, but not the problem you highlight. If BaseCard
's root has a flex styling on it and a parent can conveniently add class="justify-center"
then it's truy that changing BaseCard
can break things. But that's not the fault of merging classes. Because in the same example, if I did NOT have merging classes, I would still apply justify-content: center
with CSS or some other way. Then removing display: flex
on the root of BaseCard
would still break the parent. Except I think the story to fix the problem would take more effort because you have to track down the CSS file where the justify-content
was set.
My reply
Absolutely! Therefore, I recommend against applying any CSS styles, be it with regular CSS, inline styles, or Tailwind classes to components.
Brittle applications and cumbersome refactoring
The increased API surface and potential for breaking changes caused by attribute inheritance make refactoring components more challenging. When we need to change the internals of a component, we must carefully consider the potential impact on all instances of the component, making the refactoring process more time-consuming and error-prone.
Note from Austin Gil
I agree with this one, although I don't think it's actually that big of a deal. it would really require changing between two completely different elements, and in my experience, it's very rare to change the root element. A bit more work once in a while is worth it for me.Best Practices for Component Design
To avoid the pitfalls of attribute inheritance and create more maintainable and robust Vue components, consider following these best practices for component design:
Focus on a minimal and well-defined public API
Adhering to the Interface Segregation Principle, strive to create components with a minimal and focused public API. This approach reduces the likelihood of breaking changes, makes it easier to refactor components, and prevents parent components from depending on implementation details. A well-defined public API encapsulates the component’s internals, reducing the risk of leaky abstractions and making our components more modular and maintainable.
Use variant props instead of overriding styles with classes
Instead of using the class
attribute to override component styles from the parent component, consider using variant props. Variant props allow us to define specific styling variations for a component that we can toggle on and off by passing a prop. This approach encapsulates the component’s internal styling implementation, preventing parent components from depending on the component’s inner CSS implementation details and reducing the risk of breaking changes when modifying the component’s styles.
For example, instead of:
<BaseCard class="bg-red-500" />
Use:
<BaseCard variant="error" />
You can read more about this topic in one of my previous article about effectively working with Tailwind CSS in Vue components.
Make all attributes explicit by defining props
By explicitly defining props for all attributes a component should accept, we can avoid relying on attribute inheritance and create a more focused public API with a small surface area. This approach also encapsulates the component’s internals, preventing leaky abstractions and ensuring that parent components do not depend on unnecessary details.
For example, if we have an image component like this:
<BaseImage src="/path/to/image.jpg" alt="An example image" />
Explicitly define src
and alt
props:
const props = defineProps<{
src: string;
alt: string;
}>();
This approach makes the public API of the component explicit. By only allowing us to rely on explicitly defined props, whenever we change the root element of a component (e.g., from <img/>
to <div><img></div>
), we don’t have to fear any parent component relying on attributes that only make sense on an <img>
tag, as a bonus, we now can make the alt
attribute required to nudge developers using this component to not neglect a11y.
Note from Austin Gil
Here I'd mention that you only allow foralt
and src
, but img
actually has 13 attributes (+1 experimental one) and by excluding fallthrough props, you remove the ability for developers who may know better to use those attibutes. It's crippling
My reply
I see this more as a positive of this approach! I can limit the API to only the attributes that matter. And make properties likealt
mandatory. Or even more elaborate: many people slap loading="lazy"
on every img
tag by default. I can imagine building a BaseImage
component that (only in the dev environment) makes the lazy
value for the loading property invalid for images above the fold and throwing a runtime error with a custom property validator.
React’s Approach to Attribute Inheritance
When it comes to attribute inheritance, Vue and React take different approaches. Let’s compare the two:
Vue’s attribute inheritance
As discussed throughout this article, Vue automatically forwards attributes from parent components to child components’ root elements. While this feature can be convenient, it also leads to a more extensive API surface area, increased risk of breaking changes, and more challenging refactoring.
React’s approach
React, on the other hand, does not have attribute inheritance by default. When passing attributes to a child component in React, we must explicitly define and pass props for each attribute. This approach encourages better encapsulation of component internals.
Some developers in the React community mimic attribute inheritance by passing a rest
object containing all remaining props and spreading them onto the child component’s root element. However, doing so can introduce similar problems to Vue’s attribute inheritance.
Wrapping It Up
In conclusion, while convenient, attribute inheritance in Vue components can lead to several problems. Automatic attribute inheritance can save developers precious time when creating new components. However, as discussed in this article, it can also introduce potential issues, especially in the long run.
We can create more maintainable and robust components by following best practices for component design, such as encapsulation and keeping the public API surface area as small as possible. Ultimately, being aware of these pitfalls and adhering to best practices will result in more reliable and maintainable code in your Vue projects.