By Benoît Rouleau (former staff)
Nov 8, 2021
This is the first part in a three-part series of articles targeted at developers who regularly use Vue.js version 2 and want to up their game.
This first post provides a refresher on some framework concepts and fundamentals to get you ready for the next two parts. The second article will cover two commonly used libraries, Vuex and Vue Router. The final article will dive into more advanced features, and how to leverage them to achieve cleaner and more maintainable code.
Let's level up!
This probably goes without saying, but Vue 2 is pretty powerful. It’s flexible and fairly straightforward to use. Still, there are definitely ways to make your life easier when working with Vue 2 and I hope that the patterns I introduce in these articles will help.
I’ll be the first one to admit, though, that what I’ll show you may not constitute “best practices.” But, they’ve certainly helped me get more comfortable and productive in Vue, especially when working on client projects, such as an account management SPA for Alltrue (~70 pages, 100+ reusable components).
While there is a lot more to Vue than I could possibly write about, I hope that the tips & tricks in these three articles provide a good amount of practical value to you and your team.
Slots vs Props
I’ve long felt slots are somewhat of an underrated feature of Vue. A lot of Vue developers seem to favor props as the de facto way of passing content to a component.
I believe props often end up being used in places where slots would make more sense.
Consider this example:
We have to create a Badge component with an icon on the left and a label on the right. We could reach for two props: icon and label.
While that would work, slots are a better solution for two reasons:
Embedded Vue components
Here is the badge example using props:
Here it is using slots:
Yes, props can contain HTML, but the HTML needs to be rendered with v-html, which is a lot more complicated than just dropping in HTML. Props definitely cannot contain Vue components.
Okay, still not convinced? Let's review a more real-world example.
Let’s say you wanted to wrap that “Free Shipping” in a
There are many reasons why we might want to use HTML or components where we would normally accept plain text, and they’re not always obvious but here are a few to consider:
Inline formatting (bold, italics, underline, etc.) for branding or emphasis
Footnote references and other superscripts/subscripts
Inline images/icons (e.g. custom emojis)
Transitions between different texts (e.g. news ticker)
Compare these two snippets:
Do you see what I mean? Slots allow us to provide a more expressive API for our components, where content is visually distinct from functionality and other details. If you think about it, most HTML elements have a “slot” for their main content, so this is effectively making our Vue code more consistent with HTML.
Of course, slots cannot always replace props, and even when they can, it doesn’t mean they are the right tool for the job. But in cases where both seem equally appropriate, I’d suggest leaning towards slots..
Conditional Rendering With Slots
On the surface, it might seem like props wins when it comes to having power over how things are rendered, but that’s not the case. When using slots, everything is available in the this.$scopedSlots object. Through that object, you can know whether content has been provided or not for a given slot, and carry out different actions as a result. For instance, we could omit rendering the container of a slot if it’s empty, like this:
Notice how in the div, this technique can also be used to render extra space between a slot and its surroundings if that space is needed.
You might wonder why I use $scopedSlots instead of $slots. Despite its name, $scopedSlots includes all the slots, both scoped and unscoped, whereas $slots only includes the non-scoped slots. This difference exists for historical reasons, but all slots work the same way internally since Vue 2.6, so I would recommend using only $scopedSlots.
Something important to keep in mind when using $scopedSlots is that it’s not a reactive property, so it shouldn’t be used inside a computed property for fear of it not getting refreshed when it should. In other words, if we wanted to add a hasDescription computed property in the last example (which would simply return this.$scopedSlots.description ? true : false), it wouldn’t work reliably. The computed property would initially evaluate to either true or false and never change again, even if the slot’s content dynamically changed. The solution is to use a method instead (i.e. hasDescription()), which gets evaluated on every render of the component.
One of the most basic aspects of slots is that they can include default content:
But what you may not know (because you’ve been favoring props instead of slots) is that the default content can, in turn, have slots as well:
Now you may say to yourself, “okay, that’s cool, but what happens to the nested slots when I provide content to the content slot?” You’ve probably already guessed it. Those child slots are never rendered.
This may seem limiting but it actually allows for a pretty cool pattern: we’re able to provide two different APIs from the same component which the consumer can choose between depending on the desired level of abstraction. Let’s imagine we have a Card component:
Implicitly, the consumer of this component can either provide content to the 3 inner slots (image, title, and description) or use the content slot, which would then override the predefined structure of the card. Slots are inherently parent-child in structure. The idea is that most of the time, the parent slot is ideal because we don’t want to repeat the markup of the image, title, and description every time we need a card with these common properties. But having the child slots available means you have the ability to customize the whole card body if you want (say for a special type of card).
Perhaps a cleaner implementation would be to make the outer slot the default one (i.e. remove name="content"), so it can be used like this:
or like this:
Of course, you could create two components instead, but where would the fun be in that?
Base components are components which wrap simple HTML elements while adding your own project-specific abstractions and styles on top of them. Some common examples would be buttons, links, and form elements. Below is a simple example of this.
One best practice is to add v-on="$listeners" to the element you’re wrapping. This ensures that all native events emitted by this element (e.g. click on a
>, focus on an
>, etc.) can be listened to, without having to specify all of them (e.g. @click="$emit('click', $event)"). Here’s a basic example which includes $listeners.
Code example 2
But this can become a little tricky when the base component itself needs to listen to a particular event. For instance, it is very common for an input to listen to the native input event, and emit it again with only the updated value instead of the whole $event, so the component can support v-model:
Unfortunately, that means you cannot use v-on="$listeners" because v-model implicitly adds an input listener on the component, which then gets forwarded to the native input, and you don’t want that. You only want that listener to listen to the re-emitted event, not to the native one. One solution is to remove input from $listeners, which can be done with a computed property (since $listeners is reactive, unlike $scopedSlots):
And in the template:
A Refresh On v-model
I casually mentioned v-model in the previous section and before I go on, I think it’s worth doing a little refresher on v-model.
Basically, the v-model directive allows you to create a two-way binding. Think of it like this: you can bind a form input element and make it change the Vue data property (of another element) when the user changes the content of the field.
In this example, the “value” corresponds to another Vue data property on the page: :
This is basically syntactic sugar for:
Interestingly, the type of input has an effect on the implementation. Case in point:
is equivalent to:
Notice :value became :checked, and @input became @change. The full mapping between input types and bound properties/events can be found in the Vue documentation.
Using v-model In A Base Component
If you are going to use v-model in a base component, it is good practice to respect this mapping. For example, a Checkbox component which supports v-model should accept a checked prop, and emit a change event. But that’s not enough for v-model to work properly on that component; you also need to set the model option:
If we go back to our earlier input base component example with all this in mind:
But we can make it much cleaner by employing v-model, which is possible thanks to computed property setters! All we need to do is add an innerValue computed property:
And then bind it to the native input with v-model:
Specializing a component is achieved by creating a new component (more specific / concrete / featureful) which wraps another one (more generic / abstract / base). I find it useful to apply some OOP principles to component design to keep things separate and clean. If you have components spanning multiple layers of abstraction (e.g. unstyled > base with styles > connected to data source) or components which are more specific versions of others (e.g. SelectCountry, InputZipCode, ButtonSubmit, etc.), you should consider taking this route.
Also, it’s good to note that there is an extends feature in Vue, but the truth is, most of the time you won’t need it and it will just complicate your development. It can be useful to avoid duplication in certain circumstances (when multiple components extend the same abstract component and they all need the same props) but otherwise it’s something you should consider carefully, as everything gets inherited in it, not just props: data, computed, methods, etc. In other words, extends is pretty much the same as applying a whole component as a mixin.
Okay, let’s apply the idea of specialization to imagine what SelectCountry would look like:
As you can see, it wraps the Select component and forwards all the $props and all the $listeners it receives to it. For now, let’s assume the two components accept exactly the same props, though it will definitely not always be the case. Also note that you don’t have to forward $attrs since that is done automatically by Vue.
On top of that, SelectCountry pre-fills some of the Select’s slots with content (label and options), while still allowing you to override them, thanks to the
Now, what if you want to change some of the props which SelectCountry accepts? Maybe some of Select’s props don’t make sense for SelectCountry. Maybe some make sense, but they should have a different default value or a subset of valid values. And maybe props for some of the stuff added to SelectCountry (for instance, a continent prop to limit the list of countries to a specific continent) are missing.
Thankfully, Select and SelectCountry are completely independent, so you can freely add, remove, and change props in SelectCountry. You just need to adjust the way you forward props to Select, to only those which are supported. Instead of v-bind="$props", you could either forward them manually (e.g. :value="value" :required="required" etc.) or create a propsToForward computed property, similar to the listenersExceptInput property from an earlier tip.
That’s it for part 1. Stay tuned for Part 2 where I get into Vuex and Vue Router.