Components
Commit, stash, or discard your changes and then switch to the 3-components
branch:git checkout 3-components
In this module we will practice building components, the primary mechanism of organizing code in Vue. We’ll do that by refactoring our existing application to be more component-driven.
Extract the session card to a component
One everyday use of components is simply to extract markup for reuse or just to simplify a template. This is exactly like you would extract a method when refactoring code.
Theres a lot of code related to showing each “card” that displays the details of a single session. We’d like to simplify our markup code by extracting a component. We can also reuse the component on other pages. In the future we might want to have a Speaker profile page, and show the sessions that speaker is giving. It would be nice to reuse all this markup on that page too.
Our first reusable component
Make a new file in the components
folder called SessionCard.vue
. This is where we’ll store the markup and behavior that we want to extract from the main page.
Every component needs a template, so add an empty set of <template></template>
tags in the new file.
Jump back to SessionList.vue
and cut the entire <div class="card" v-for="session in filteredSessions" :key="
session.id
">
tag, all its children, all the way to the corresponding closing tag. Paste it inside the template tag in SessionCard.vue
.
Remove the v-for
and :key
attributes. We will do the looping in the parent component.
We also need to move some of the CSS styles from SessionList.vue
to SessionCard.vue
. Cut the.card-body
, .card-text
, and .footer
CSS styles from the bottom of SessionList.vue
and paste them into a new <style scoped></style>
tag at the bottom of SessionCard.vue
.
Your SessionCard.vue
file should look like this:
<template>
<div class="card">
<div class="card-body">
<span
class="badge"
:class="{
'bg-success': session.level === 'Introductory and overview',
'bg-warning': session.level === 'Intermediate',
'bg-danger': session.level === 'Advanced',
}"
>{{ session.level }}</span
>
<h5 class="card-title">{{ session.title }}</h5>
<h6 class="card-subtitle mb-2 text-body-secondary">{{ session.speakers.join(', ') }}</h6>
<p class="card-text">{{ session.excerpt }}</p>
<div class="footer pt-2">
<span> {{ session.day }} {{ session.startTime }} - {{ session.endTime }}</span>
<a href="" class="btn btn-primary">Details</a>
</div>
</div>
</div>
</template>
<style scoped>
.card-body {
display: flex;
flex-direction: column;
height: 100%;
}
.card-text {
flex: 1;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border-top: 1px solid #ccc;
}
</style>
Red-Squiggle Driven Development
If you have all the Vue extensions turned on in your editor, every usage of session
is underlined with a red squiggle. Most of programming is making the red squiggles go away and today is no exception.

Thats a brutal error to read, but the gist of it is that session
is not defined - vue doesnt know where to get its data from.
Extracting a component is a lot like extracting a function: You might have to pass parameters that the component needs to work. In Vue, component parameters are called props
.
We know our component needs to take an input called session
of type SessionOverview
. You can see this type definition in the models.ts
file.
<script setup lang="ts">
import { SessionOverview } from '../models';
export interface SessionCardProps {
session: SessionOverview;
}
defineProps<SessionCardProps>();
</script>
Lets add a script block to the top and define that parameter, or prop. Since we’re writing typescript, we’ll define an interface that documents what props our component needs. We can get access to the props props by calling the defineProps
macro.
As soon as you do this, the red squiggles should go away! The vue compiler now knows what “session” means: it is a prop that a parent component is expected to provide.
Using the new component
We’re almost ready to use the new component in SessionList.vue
. First we need to import it for use: add an import at the top of the file that brings the code in:
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { SessionOverview } from '../models.ts';
+ import SessionCard from './SessionCard.vue';
Now we can use the component inside the main template. Stick in a call to the <SessionCard />
component inside the .speaker-grid
div. Set up the v-for
and :key
bindings like we had before, so that the entire component is repeated for each session in the list.
<div class="speaker-grid">
<SessionCard v-for="session in filteredSessions" :key="session.id" />
</div>
Then go remove v-for
and :key
from the div
in the SessionCard. Since we’re doing the repeating logic in the parent component, the child no longer needs it.
It still doesn't work because we didn’t pass the session into the component. Like calling a function but forgetting the arguments! Lets fix it:
<div class="speaker-grid">
- <SessionCard v-for="session in filteredSessions" :key="session.id" />
+ <SessionCard
+ v-for="session in filteredSessions"
+ :key="session.id"
+ :session="session"
+ />
</div>
Add the :session="session"
binding to the call in SessionList.vue
.
:
, for example using session="session"
instead of :session="session"
. Why?
Great! You’ve extracted your first component. Now you could reuse this SessionCard
in any context you wanted to show session overviews. We’re currently using it for the overall sessions list, but imagine if speakers had pages that displayed their talks: you could reuse this card on those pages too.
The general procedure for extracting a session is just like extracting a function:
- Create a new empty component with the desired name. I usually go ahead and make empty
<script>
,<template>
and<style>
tags right away.
- Cut the markup from the source and paste it into the new component
- Fix the squiggles! Create props to move needed data from the parent to the new component. Make sure you access them off the
props
variable.
Creating a RadioButtonGroup component
This first component was fairly straight forward: it only took a prop in, it didn’t communicate back up to its parent.
We’ll build another component that does both. We have some markup and logic related to handling the button group that filters by level. That button group seems like a useful concept we could use elsewhere in the app. Rather than duplicating it, we can create a reusable component.
Migrating markup
Create a components/ButtonGroup.vue
file with an empty tags:
<script setup lang="ts">
</script>
<template>
</template>
Cut and paste the button group markup from SessionList.vue
to ButtonGroup.vue
in the template area. This is the div with the button-group
class, and all its children.
Since this component is generic, it shouldn’t use variables like level
or levels
, instead lets call them option
and options
. Do a find and replace to convert level
→ option
and levels
→ options
.
Defining props
Just like before, we’ll also define an interface called ButtonGroupProps
that documents the inputs our ButtonGroup component expects:
modelValue
of typestring
: this is the currently selected option.
options
of typestring[]
: this holds all the different button labels we’re going to have in the group
Unlike before, we’ll be accessing the props inside the script block for some of our logic, so we need to get an access to the props by assigning the result of defineProps
to a variable:
export interface ButtonGroupProps {
modelValue: string;
options: string[];
}
const props = defineProps<ButtonGroupProps>();
Inside the template section of a component, props are automatically in scope; we don’t have to use props.options
or props.modelValue
, we can just use options
and modelValue
. But inside the script block, you have to access them off the props
object.
Defining events
We’ll also need to raise events back to the parent to tell it when the selected button has changed. We can use the defineEmits
macro to tell Vue and typescript what events our component raises.
This component supports one event: update:modelValue
which will be fired when the selected button changes. The defineEmits
macro returns a function you can call to raise the events you define.
const emit = defineEmits(['update:modelValue']);
Raising events
We’re going to use slightly different logic from before to handle the click events from the buttons. Instead of updating a Ref like selectedLevel
, we will raise the update:modelValue
event and expect our parent to update its own state. This keeps us decoupled. Lets write that function, which should look familiar from an earlier module:
function handleSelectOption(option: string) {
if (option === props.modelValue) {
emit('update:modelValue', '');
} else {
emit('update:modelValue', option);
}
}
Like before, we check to see if the new option
matches the currently selected option stored in props.modelValue
. If it matches, we’ll notify our parent to clear its selection by passing ''
. If the option has changed, we’ll raise the update:modelValue
event and pass that option along.
All in, our new component should look like this:
<script setup lang="ts">
export interface ButtonGroupProps {
modelValue: string;
options: string[];
}
const props = defineProps<ButtonGroupProps>();
const emit = defineEmits(['update:modelValue']);
function handleSelectOption(option: string) {
if (option === props.modelValue) {
emit('update:modelValue', '');
} else {
emit('update:modelValue', option);
}
}
</script>
<template>
<div class="btn-group" role="group" aria-label="Basic radio toggle button group">
<template v-for="option in options" :key="option">
<input
type="radio"
class="btn-check"
name="btnradio"
:id="option"
autocomplete="off"
@click="handleSelectOption(option)"
:checked="option === modelValue"
/>
<label class="btn btn-outline-primary" :for="option">{{ option }}</label>
</template>
</div>
</template>
Hooking it up in SessionList
Lets hook it up in the parent component, SessionList.vue
First add an import to bring the new component in scope:
import { computed, onMounted, ref } from 'vue';
import { SessionOverview } from '../models.ts';
import SessionCard from './SessionCard.vue';
+ import ButtonGroup from './ButtonGroup.vue';
Then wire up the ButtonGroup as follows, passing the levels
and selectedLevel
in as options
and :model-value
respectively. We’ll handle the update:modelValue
event by writing an inline function using @update:model-value="(newValue) => selectedLevel = newLevel"
.
+ <ButtonGroup
+ :options="levels"
+ :model-value="selectedLevel"
+ @update:model-value="(newValue) => (selectedLevel = newValue)"
+ />
Give it a shot! Everything should be working well now.
Removing some boilerplate
The choice of modelValue
and update:modelValue
as the name of our events and props was deliberate: it lets us take advantage of Vue’s component v-model
support.
If your component speaks the protocol of taking an input prop called modelValue
and notifies about changes via update:modelValue
you can use the v-model
directive right on the component itself.
So we can simplify our button group use in SessionList by removing :model-value
and @update:model-value
bindings and replacing them with v-model
. Much nicer!
<ButtonGroup :options="levels" v-model="selectedLevel" />
v-model
into :model-value
and @update:modelValue
- sometimes you’d like to run side effects when a component model changes, and instead of writing an inline handler you can put a function call in there to process the change anyway you want.
Recap
- You learned how to extract a component to share some markup across multiple screens
- You learned how to pass input props to a component
- You learned how to raise events from a component to communicate with its parent
- You learned about how
v-model
is a shorthand for an input prop calledmodelValue
and an output event calledupdate:modelValue
Further Reading
Bonus Exercises
- Advanced: put some console logs in the lifecycle events of ButtonGroup and see when they run
- Badge component: Bootstrap has a Badge component. We will want to use this a lot. Build a reusable component that renders the correct markup for a badge. Use a slot for the content of the badge and a string prop for the color.