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 :keyfrom the div in the SessionCard. Since we’re doing the repeating logic in the parent component, the child no longer needs it.

💡
Reload your browser and open your dev console. Take note of the warnings!

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.

What warning or error do you get if you forget to use a : , 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:

  1. Create a new empty component with the desired name. I usually go ahead and make empty <script> , <template> and <style> tags right away.
  1. Cut the markup from the source and paste it into the new component
  1. 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 leveloption and levelsoptions.

Defining props

Just like before, we’ll also define an interface called ButtonGroupProps that documents the inputs our ButtonGroup component expects:

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" />
It’s good to know how to “desugarv-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

Further Reading

Bonus Exercises