Forms & Reactivity
Setup
Commit your changes if you want (git commit -am “module 1”
) and then jump to the next snapshot with git checkout 2-forms-reactivity
.
Bring up your browser and see the new changes. We’ve added some markup for some session filters:
- A textbox we will be able to use to search for sessions with matching title, speakers, excerpt, or tags
- A track dropdown where we’ll be able to pick a track like Data or Architecture
- A set of buttons to filter the sessions to a desired skill level
None of these are wired up yet; that’s what we’ll tackle in this module.
Supporting Search
When using the filters, the matching results are a view of the underlying data in the sessions
variable. In other words, we can compute the list of matching sessions on demand. It sounds like a perfect use for a Vue computed
property!
Tracking text in a textbox
Let's start by adding a variable to keep track of the text the user has entered into the search box.
Add a ref
called searchText
with a default value of an empty string.
Add the v-model
directive to the input field pointing at the searchText in the template. Once you do this, Vue will keep the text in the search box synchronized with the ref value.
onMounted(() => {
console.log(data);
});
});
+
+const searchText = ref('');
</script>
<template>
...
<div class="row">
<div class="col">
<div class="form-floating">
- <input id="search" class="form-control mb-4" type="text" placeholder="Search" aria-label="Search" />
+ <input id="search" class="form-control mb-4" type="text" placeholder="Search" aria-label="Search" v-model="searchText" />
<label for="search">Search</label>
</div>
</div>
At this point, whenever you type in the text box, Vue will update the string stored in the searchText
ref. We’ll next add a computed property that takes in the full session list and the search text and calculates the list of sessions that match
Computing the matching sessions
Add computed
to the list of functions imported from vue
at the top of the file so that we can use it.
<script setup lang="ts">
- import { onMounted, ref } from 'vue';
+ import { computed, onMounted, ref } from 'vue';
import { SessionOverview } from '../models.ts';
Now, at the bottom of the <script>
tag, add the computed property called filteredSessions
with the following logic:
- if the current search text is 2 or fewer characters, we’ll return the full list of sessions without filtering
- otherwise, calculate the sessions that match title, excerpt, speaker, or tags
- Searching by text should be case insensitive
The method will look something like this:
const filteredSessions = computed(() => {
let currentSessions = sessions.value;
if (searchText.value.length <= 2) {
return currentSessions;
}
const term = searchText.value.toLowerCase();
return currentSessions.filter(
(s) =>
s.title.toLowerCase().includes(term) ||
s.excerpt.toLowerCase().includes(term) ||
s.speakers.some((speaker) => speaker.toLowerCase().includes(term)) ||
s.tags.some((tag) => tag.toLowerCase().includes(term))
);
});
Applying the template
Currently the template uses sessions
, which is always the full list of 211 sessions. Wherever we want to reference the sessions matching the user’s filters, we’ll need to replace sessions
with filteredSessions
:
- use
filteredSessions.length
for the first number inShowing X of 211 talks
- Update the
v-for
directive to usefilteredSessions
instead ofsessions
- Update the
v-if
wrapping the “No sessions found” message to also usefilteredSessions
<!-- we're going to add filters in a later module that will update these counts -->
- <p>Showing {{ sessions.length }} of {{ sessions.length }} talks</p>
+ <p>Showing {{ filteredSessions.length }} of {{ sessions.length }} talks</p>
<!-- introductory and overview -->
- <div class="card" v-for="session in sessions" :key="session.id">
+ <div class="card" v-for="session in filteredSessions" :key="session.id">
<div class="card-body">
<span
<!-- show this if there are no sessions -->
- <p v-if="sessions.length === 0">No sessions found</p>
+ <p v-if="filteredSessions.length === 0">No sessions found</p>
</div>
Now give it a try in the browser!
Notice how whenever you type Vue computes the new list of filtered sessions and automatically updates multiple places in the screen to keep it in sync
Filtering by Track
Now we want to add support for filtering by track:
- We’ll set up a
ref
and av-model
binding to keep track of the current selection
- We’ll update the
<select>
tag to have an<option>
from each unique talk in the session list, rather than hard-coding the options
- We’ll incorporate the track variable into the filtering logic
V-Model for Select
Like before, we’ll add a ref
to hang onto the currently selected track. Create a new ref
called selectedTrack
that defaults to an empty string.
const searchText = ref('');
+ const selectedTrack = ref('');
Then we can add the v-model
directive to the <select>
element so that Vue will syncronize the selected option with your selectedTrack
ref.
<div class="form-floating">
- <select id="track" class="form-control">
+ <select id="track" class="form-control" v-model="selectedTrack">
<option value="">All</option>
Now, as you pick different options from the <select>
element, Vue will update the selectedTrack
ref automatically, and update anything that depends on it.
Computing the list of options
It would also be nice if we could use a loop to generate all the option
tags under the select. That will let us skip writing some tedious markup.
It would also be nice if that list could be figured out automatically, rather than having to be maintained by a programmer. Lets create another computed
to do that.
Each session has one track
, but we’ll have to deduplicate them using javascript’s Set
class.
Create a new computed
called tracks
that looks like this. Put it right before the filteredSessions
definition.
const selectedTrack = ref('');
+ const tracks = computed(() => {
+ const allTracks = sessions.value.map((session) => session.track);
+ const uniqueTracks = new Set(allTracks);
+ return Array.from(uniqueTracks);
+ });
const filteredSessions = computed(() => {
The map
method converts each element of an array into another type. Here we convert each SessionOverview
object into a string holding its track. Then we use the Set
class to deduplicate them: a Set can only hold one copy of each value. Finally we convert the Set back into an array and return it
Using the options to render the select tag
Now we can replace the hard-coded <option>
tags in our template with v-for
, :key
, :value
and {{ track }}
<option value="">All</option>
- <option value="Architecture">Architecture</option>
- <option value="Career">Career</option>
- <option value="Cloud">Cloud</option>
+ <option v-for="track in tracks" :key="track" :value="track">{{ track }}</option>
</select>
Removing code is the best.
Adding selectedTrack
to the filter logic
Finally lets update the filteredSessions
computed property to actually respect the selectedTrack
, if given.
const filteredSessions = computed(() => {
let currentSessions = sessions.value;
+ if (selectedTrack.value) {
+ currentSessions = currentSessions.filter((s) => s.track === selectedTrack.value);
+ }
+
if (searchText.value.length <= 2) {
return currentSessions;
}
Give that a shot. You should be able to filter by track and by search term at the same time.
Filter by level
Feature overview
Here’s the requirements for our level filter:
- The user should not be forced to filter by any level
- Only one level filter can be selected at a time
- clicking on a level filters by that level
- clicking on the level that is already active should disable the level filter
Here’s how we’ll tacking this:
- Create a Ref to hold the current level selection
- Update the list of buttons to render from a list instead of duplicated HTML markup
- Add a function to handle click events on the buttons by updating the level selection
- Update the
filteredSessions
computed property to incorporate the selected level
Typescripting
This time we’re going to do all the typescript code first.
First create another Ref called selectedLevel
to hold onto the user’s current level selection.
Then a list of levels to drive the button bar. This time I’m not putting it into a ref. If its never going to change, Vue is happy to work with normal javascript objects and arrays too.
Finally create function to handle the click events. Inside that function we’ll do a little check:
- if the new level is the same as the current (i.e. the user clicked again on the same filter) we’ll clear the filter so that all sessions can be shown again
- otherwise, we’ll set the current level filter to the requested level
return Array.from(uniqueTracks);
});
+const selectedLevel = ref('');
+
+const levels = ['Introductory and overview', 'Intermediate', 'Advanced'];
+
+function handleSelectLevel(level: string) {
+ if (level == selectedLevel.value) {
+ selectedLevel.value = '';
+ } else {
+ selectedLevel.value = level;
+ }
+}
+
const filteredSessions = computed(() => {
let currentSessions = sessions.value;
Then we’ll update the currentSessions
computed a lot like we did for selectedTrack
: if a selectedLevel
exists, we’ll filter
the array by level.
currentSessions = currentSessions.filter((s) => s.track === selectedTrack.value);
}
+ if (selectedLevel.value) {
+ currentSessions = currentSessions.filter((s) => s.level === selectedLevel.value);
+ }
+
if (searchText.value.length <= 2) {
return currentSessions;
}
Now we have all the logic we need, and its time to connect it to the template.
Updating the markup
Firt we’ll update the button group and replace our hard-coded samples with markup driven from the levels
array and the v-for
directive.
We hit a fun wrinkle here: v-for
can only be applied to a single root element, but the markup needed by Bootstrap’s ButtonGroup has two elements: the input
and the label
. If we introduced an empty div or span on which to apply the v-for
we would break the CSS of the button group.
Luckily Vue will let us wrap them in a <template>
tag which will be elided at runtime: there will be no element wrapping each input. Create a <template>
tag and put the v-for
and :key
attributes on it as needed.
On the <input>
set:
:id
tolevel
- each element needs a unique id
@click
tohandleSelectedLevel(level)
- this attaches our method to the input’s click event
:checked
to"level === selectedLevel"
- if the current element in thev-for
is theselectedLevel
then it should be checked
On the <label>
:
- set the
:for
to"level"
so that it points to the input identified by a matchingid
- set its contents to the level variable
<div class="btn-group" role="group" aria-label="Basic radio toggle button group">
- <input type="radio" class="btn-check" name="btnradio" id="Introductory and overview" autocomplete="off" />
- <label class="btn btn-outline-primary" for="Introductory and overview">Introductory and overview</label>
-
- <input type="radio" class="btn-check" name="btnradio" id="Intermediate" autocomplete="off" />
- <label class="btn btn-outline-primary" for="Intermediate">Intermediate</label>
-
- <input type="radio" class="btn-check" name="btnradio" id="Advanced" autocomplete="off" />
- <label class="btn btn-outline-primary" for="Advanced">Advanced</label>
+ <template v-for="level in levels" :key="level">
+ <input
+ type="radio"
+ class="btn-check"
+ name="btnradio"
+ :id="level"
+ autocomplete="off"
+ @click="handleSelectLevel(level)"
+ :checked="level === selectedLevel"
+ />
+ <label class="btn btn-outline-primary" :for="level">{{ level }}</label>
+ </template>
</div>
That’s it! If everything went well, you can now filter the sessions by skill level as well. Make sure you can activate and deactivate the level filter
Recap
- You learned how to use
v-model
on a textbox and a select
- You learned how to use a
computed
to calculate new values for use in your template and keep them in sync with the underlying sources
- You learned how to listen for click events and how to set the checked property
- You learned how to use a
<template>
tag when you don’t want wrapping DOM nodes
- You learned that that data used in a Vue template doesnt have to be a a Ref or Computed, it can be plain javascript data too
Further Reading
Bonus Exercises
- Change the level filtering to use
v-model
instead of@click
and:checked
. What happens?
- Instead of three separate refs for each filter variable, convert it to one
reactive
object
- As a conference attendee, it would be easier to to filter by track if the options in the select were sorted
- As a conference attendee, I’d like to see all sessions by my favorite speaker. When I click on a speaker name, i’d like to see all sessions by that speaker.
- As a conference attendee, i’d also like to filter sessions by tag. Add a multiple select that I can use to select more than one tag, and show sessions that have at least one of the selected tags