The first component
In short: A Vue component is a template with data: it describes how the screen looks for that data, and Vue changes only the difference on the page.
Problem
The field loses focus
The module "JavaScript in the page" ended with a cart drawn from the data by one render(). Here every change of the quantity field calls render(). The buyer wants 15 bottles of milk: they click into the field, delete 2 and type 15. Try it.
Console
After the delete the field is empty and no longer focused: 1 and 5 go nowhere. render() replaced the field with a new one on the very first key. The new field looks the same, but it is a different element, and the focus was on the old one. Touching separate places by hand forgets places. Drawing everything again loses the focus. There is a third way.
The idea
The interface as a function of the data
render() had a good idea: the screen is built only from the data. With the same data the screen is the same, whatever was on it before. The mistake was in the last step: render() removes and creates the elements itself.
So the work is split in two:
- You write a function that says how the screen looks for these data. It does not touch the page; it returns a description: here is a
<main>, in it an<h1>with the text "The cart", then a field with the value 2. - The library holds the real DOM. The first time it builds it from the description. On every change of the data it takes the new description, compares it with the old one and changes only the difference on the page.
Such a function is called a component. In short: the screen is a function of the data, and the component is that function. React and Vue do this in different ways. The idea is the same, and choosing between them is a matter of the team, not of a "better" library.
See it
The first component
In Vue a component is a .vue file (Single File Component) with up to three parts: <script setup> with the data and the functions, <template> with the description of the screen and, optionally, <style>.
<script setup>const product = { name: 'Fresh milk', price: 1.6, stock: 35 };</script>
<template> <main> <h1>{{ product.name }}</h1> <p>Price: {{ money(product.price) }}</p> </main></template>- Everything
<script setup>declares (variables, functions, imported components), the template can see. {{ product.name }}is a JavaScript expression in double curly braces. Its result becomes text on the page.- The name of the component is the name of the file:
App.vueis the componentApp. - The sandbox puts
Appon the page by itself. In a real project one line inmain.jsdoes it:createApp(App).mount('#app').
Console
Change the price or the stock in the object product and press "Run". You do not touch the description of the screen: the screen follows the data.
The idea
The rules of the template
The template is HTML, but Vue compiles it to JavaScript and reads its own signs in it. That is why it has rules of its own.
{{ expression }}in text:<h1>{{ product.name }}</h1>.:before an attribute means "the value is an expression":<img :src="product.image" :alt="product.name" />.:altis short forv-bind:alt. Without:the value is the text "product.name", letter by letter.{{ }}does not work inside an attribute:alt="{{ product.name }}"is the text{{ product.name }}.@before an event:@click="add"(short forv-on:click). A short expression works too:@click="count++"or@click="add(product)". Vue wraps it in a function that runs on click.classandforstayclassandfor, as in HTML.- Several roots are allowed: the template can have a
<header>and a<main>side by side. - Tags without content may be closed with
/>:<input />,<ProductCard />.
Try it
Find the bug
A click on "Add" writes nothing to the console.
Pick the line with the bug.
onclick="add()" is a plain HTML attribute. Vue puts it on the page as it is, and on click the browser looks for a global function add. There is none, so the console says "ReferenceError: add is not defined". The add from <script setup> is called with @click="add".Try it
Fill in
The screen reader should read the product name as the description of the image.
<img :src="product.image" ="product.name" />:alt="product.name": the colon says that the value is an expression. The full form is v-bind:alt.The idea
Components from components
The shop page has parts: the top with the menu, the product card, the cart. Each part becomes a component in its own file, and a bigger component uses them as tags. Then App is not one long function but a few lines that say what is on the page.
<template> <header> <a href="/">The shop</a> <nav>…</nav> </header></template><script setup>import ShopHeader from './ShopHeader.vue';import ProductCard from './ProductCard.vue';</script>
<template> <ShopHeader /> <main> <ProductCard /> </main></template>The template sees an imported component directly. Without the import, Vue does not know what ShopHeader is: the console says "[Vue warn]: Failed to resolve component: ShopHeader", and an unknown tag stays on the page. The name starts with a capital letter by convention (PascalCase), so it differs from HTML tags. Vue also accepts <shop-header />.
Try it
Put the page together
ShopHeader and ProductCard are ready in their files. Put App together: ShopHeader at the top, and the product card in main, under the heading "Dairy".
Console
Solution
App.vue<script setup>
import ShopHeader from './ShopHeader.vue';
import ProductCard from './ProductCard.vue';
</script>
<template>
<ShopHeader />
<main>
<h1>Dairy</h1>
<ProductCard />
</main>
</template>
ShopHeader.vue<template>
<header>
<a href="/">The shop</a>
<nav>
<a href="/bread">Bread</a>
<a href="/milk">Milk</a>
<a href="/cart">Cart</a>
</nav>
</header>
</template>
ProductCard.vue<script setup>
const product = { name: 'Fresh milk', price: 1.6, stock: 35 };
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Price: {{ money(product.price) }}</p>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
<script setup> imports the two components, and the template uses them as tags: <ShopHeader /> before <main>, <ProductCard /> in <main>. The template may have two roots side by side. The check looks for the top part by role: a <header> inside <main> is no longer the top of the page, and without the import there is no <header> at all.
The idea
Only the difference
Here is the cart from the problem as a component.
ref('2') is the memory of the component: a value Vue watches. When it changes, Vue draws again the components that read it. It is the topic of the module "State". Here it is only so that something can change.
Console
Do the same as in the problem: click into the milk field, delete 2 and type 15. The field keeps the focus and what you typed, and the total becomes €25.20. The component describes the whole screen on every key, like render(). The difference is that the page is not built again. The library sees that in the new description the field is in the same place with the same tag, and changes only its value and the text of the total.
See it
See the comparison
<script setup>import { ref } from 'vue';const money = (x) => '€' + x.toFixed(2);
const milk = ref('2');const bread = ref('1');</script>
<template> <main> <h1>The cart</h1> <label> Fresh milk <input type="number" :value="milk" @input="milk = $event.target.value" /> </label> <label> White bread <input type="number" :value="bread" @input="bread = $event.target.value" /> </label> <p>Total: {{ money(Number(milk) * 1.6 + Number(bread) * 1.2) }}</p> </main></template>Step by step, in words
- The buyer has deleted 2 and typed 1: the data say milk = "1". In the middle is the last description the component returned; on the right is the DOM, with the focus in the milk field.
- The buyer types 5. @input writes "15" into milk: the data change, and Vue knows that the template reads milk.
- Vue compiled the template into a render function. Because milk changed, it runs it again, and it returns a new description: a tree of plain objects.
- The library compares the new description with the previous one. Same tags in the same order; two differences: the value of the field and the text of the total.
- Only the two differences reach the DOM. The <input> is the same element as before, so the focus and the cursor stay in it.
- You describe the whole screen for the given data, as with render(). The library finds what changed and touches only that. Both libraries do this, each in its own way.
The description is cheap: it is a tree of plain objects, and the component makes it again every time. The DOM is the expensive part, so the library touches only the differences there. You write as with render(): the whole screen from the data. The price of render() (lost focus, thousands of new elements) is not paid.
More precisely, if you are curious
Virtual DOM. The description is often called the virtual DOM, and comparing it with the old one is called reconciliation (React) or patching (Vue). A node of the description is an object like { type: 'h1', props: { children: 'The cart' } }.
The two ways. The template compiles to a render function that returns the nodes of the description. The compiler marks which parts never change, and the comparison skips them. Vue remembers which data each component reads (reactivity) and runs again only the components whose data changed. In both cases there is a new description at the end, a comparison and a few changes in the DOM.
Text is text. {{ product.name }} puts in text, like textContent. A name like <img src=x onerror=alert(1)> shows as characters and does not become a tag. HTML from a string goes in only with v-html, and the name tells you to be careful.
Options API. In older code a component is an object: export default { data() { … }, methods: { … } }. It still works, but new code uses <script setup> (Composition API).
v-model. The pair :value and @input has a shorthand, v-model. It belongs to the lesson on forms.
Exercise
The product card
ProductCard holds the object product (White cheese). Make the card: the name as a level-two heading, "Price: €7.90" (with money), "In stock: 12 pcs" and an "Add" button whose name for the screen reader is "Add White cheese". A click on the button writes "Added: White cheese" to the console (the function add is ready). Then put App together: ShopHeader at the top, and the card in main, under the heading "Dairy".
Console
Solution
ProductCard.vue<script setup>
const product = { name: 'White cheese', price: 7.9, stock: 12 };
const money = (x) => '€' + x.toFixed(2);
function add() {
console.log(`Added: ${product.name}`);
}
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Price: {{ money(product.price) }}</p>
<p>In stock: {{ product.stock }} pcs</p>
<button type="button" :aria-label="`Add ${product.name}`" @click="add">Add</button>
</article>
</template>
App.vue<script setup>
import ShopHeader from './ShopHeader.vue';
import ProductCard from './ProductCard.vue';
</script>
<template>
<ShopHeader />
<main>
<h1>Dairy</h1>
<ProductCard />
</main>
</template>
ShopHeader.vue<template>
<header>
<a href="/">The shop</a>
<nav>
<a href="/bread">Bread</a>
<a href="/milk">Milk</a>
<a href="/cart">Cart</a>
</nav>
</header>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
Text from the data goes in {{ … }}: {{ product.name }}, {{ money(product.price) }}, {{ product.stock }}. The name for the screen reader is an attribute from an expression, with a colon: :aria-label="`Add ${product.name}`". Without the colon the button is literally called "Add ${product.name}", and the check does not find it. The button calls the function with @click="add".
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Accessibility · The DOM: the page from JavaScript · Events · A list from data
- Continues in: Props: data from above · Lists and keys · Conditions: empty, error, loading · State
- Tasks on this topic: Components