Vue.js looks like a big framework, but day-to-day work leans on a handful of ideas: reactive data, a template that shows it, a few special attributes, computed values, and components that talk through props and events. This post walks through those ideas one at a time with a single small example, so you can read real Vue code by the end.
The one idea behind everything
Vue is a JavaScript framework for building web pages that change while you use them. Its whole approach fits in one sentence: you keep your data in variables, you describe how the page should look for that data, and Vue keeps the page in sync whenever the data changes.
You never write "find this element and change its text". You change the data, and Vue updates the page for you. When the user clicks or types, you change the data again, and the loop repeats.
Everything else in this post is one of three things: a way to hold data Vue can watch, a way to show data on the page, or a way to change data when the user does something.
Starting a project
The official way to start a Vue 3 project is one command in a terminal. You need Node.js installed, which comes with npm, the package manager used to install JavaScript libraries:
npm init vue@latest
It asks for a project name (keep the default, vue-project) and a few yes/no questions (answer "No" to everything for now), creates a folder, and prints the next commands to run:
cd vue-project
npm install
npm run dev
The last command starts a local development server built on Vite, a fast build tool, and prints a local address, starting with http://localhost:, to open in the browser. Every time you save a file, the page updates on its own.
A component is one .vue file
Vue code lives in components: reusable pieces of the page, such as a button, a form or a whole screen. Each component is usually one file ending in .vue, called a single-file component (SFC), with up to three parts:
<script setup>
// JavaScript: the data and the functions
</script>
<template>
<!-- HTML: what the page should look like -->
</template>
<style scoped>
/* CSS: how it looks, applied only to this component */
</style>
The <template> is HyperText Markup Language (HTML) with a few extras, the <style> block is ordinary Cascading Style Sheets (CSS), and scoped means those styles only touch this component. The setup attribute on <script> is what lets the template use anything you declare in the script directly, with no extra wiring.
Most examples below are pieces of one small shopping list, assembled in full near the end.
Reactive data with ref
A normal JavaScript variable can't tell anyone when it changes, so Vue can't know it should update the page. To give Vue data it can watch, you wrap the value in ref().
<script setup>
import { ref } from 'vue'
const title = ref('Shopping list')
const items = ref([
{ id: 1, name: 'Bread', bought: false },
{ id: 2, name: 'Coffee', bought: true },
])
</script>
ref() returns a small box, and the real value sits inside it under .value. That leads to the one rule that trips up every beginner:
- In
<script>, read and write through.value:title.value = 'Groceries'. - In
<template>, leave.valueout: Vue unwraps the box for you.
Change title.value anywhere in your code and every place on the page that shows the title updates. The reactivity guide covers the details; for daily work, "wrap it in ref, use .value in the script" is enough.
Showing data: {{ }} and :
Now that the data exists, the template has to show it. There are two ways, one for text and one for attributes.
For text, put a variable between double curly braces:
<template>
<h1>{{ title }}</h1>
<p>You have {{ items.length }} items.</p>
</template>
Anything valid as a single JavaScript expression works inside the braces, such as items.length or title.toUpperCase().
For attributes, like disabled, src or class, curly braces don't work. Put a colon in front of the attribute name instead, and its value becomes JavaScript rather than plain text:
<img :src="photoUrl" :alt="title" />
<button :disabled="items.length === 0">Clear</button>
Without the colon, src="photoUrl" would be the literal text "photoUrl". With it, Vue reads the variable. The colon is short for v-bind:, which you'll see in older code and in the template syntax guide.
Changing data: @ for events
Showing data is half the loop. The other half is changing it when the user does something. Put @ in front of an event name, and give it a function to run:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">Clicked {{ count }} times</button>
</template>
Clicking the button runs increment, which changes count.value, and Vue updates the button's text. That's the whole loop from the first diagram, in six lines. @ is short for v-on:, and it works with any browser event: @click, @input, @submit, @keyup.enter and so on. The event handling guide lists the handy modifiers, such as .prevent to stop a form from reloading the page.
Keeping an input in sync: v-model
Text boxes are so common that Vue gives them a shortcut. v-model connects an input to a variable in both directions: typing updates the variable, and changing the variable updates the input.
<script setup>
import { ref } from 'vue'
const newItem = ref('')
</script>
<template>
<input v-model="newItem" placeholder="Add something" />
<p>You are typing: {{ newItem }}</p>
</template>
Under the hood, v-model is just a :value plus an @input written for you. It also works on checkboxes, radio buttons and dropdowns, as the form input guide shows.
Showing things conditionally: v-if
Attributes that start with v- are called directives: instructions to Vue rather than to the browser. You've already met v-model; the next one decides whether something appears at all.
<p v-if="items.length === 0">Nothing on the list yet.</p>
<p v-else>{{ items.length }} things to buy.</p>
v-if adds or removes the element depending on whether the expression is true, and v-else must come right after it. There's also v-else-if for more branches.
A close cousin, v-show, keeps the element on the page and only hides it with CSS:
| Directive | When false, the element is… | Use it when… |
|---|---|---|
v-if | removed from the page | the condition rarely changes, so the content is often never rendered at all |
v-show | still there, but hidden | the element toggles on and off often, like a dropdown |
When unsure, use v-if. The conditional rendering guide has the finer points.
Repeating things: v-for
To show one element per item in a list, put v-for on the element you want repeated:
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
This creates one <li> for each item in items, and inside it item is the current one. The :key gives each row a unique identity, so when items are added, removed or reordered, Vue can match each row to its item and move it, instead of patching rows in place and mixing up their state. Always add it, and use a real identifier such as item.id, not the position in the list. The list rendering guide explains why the position is a bad key.
Values worked out from other values: computed
Often the page needs a number that isn't stored anywhere but can be worked out from what is, like "how many items are left to buy". You could calculate it inside {{ }}, but that gets messy fast. computed() gives the calculation a name, and Vue re-runs it only when the data it uses changes.
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'Bread', bought: false },
{ id: 2, name: 'Coffee', bought: true },
])
const leftToBuy = computed(() => items.value.filter((item) => !item.bought).length)
</script>
<template>
<p>{{ leftToBuy }} left to buy</p>
</template>
leftToBuy behaves like a ref: .value in the script, plain name in the template. You don't set it yourself; mark an item as bought and leftToBuy updates on its own.
A plain function called from the template would give the same result, so why bother? The difference is when the work happens:
computed | Plain function | |
|---|---|---|
| Runs when… | the data it depends on changes | every time the page re-renders |
| Remembers its last result | yes | no |
| Good for | values derived from your data | actions, like handling a click |
A rule of thumb: if it's a value, make it computed; if it does something, make it a function. See the computed properties guide for more. (There's also watch, for running side effects such as saving to the server when data changes. You need it far less often than beginners expect; the watchers guide covers it.)
Splitting the page into components
Once a file grows past a screenful, you break part of it into its own component. The two then need to talk, and Vue has one simple rule for that: data flows down through props; messages flow up through events.
- Props are inputs a parent passes into a child, like arguments to a function. The child reads them but never changes them.
- Events are how a child tells its parent that something happened. The parent decides what to do about it, usually by changing its own data.
Here is a child component that shows one item and reports clicks:
<!-- ShoppingItem.vue -->
<script setup>
defineProps({
item: Object,
})
defineEmits(['toggle'])
</script>
<template>
<li :class="{ done: item.bought }" @click="$emit('toggle')">
{{ item.name }}
</li>
</template>
<style scoped>
.done {
text-decoration: line-through;
}
</style>
definePropsdeclares what the component accepts. The template can then useitemdirectly. See the props guide.defineEmitsdeclares which events it can send, and$emit('toggle')sends one. See the component events guide.:class="{ done: item.bought }"adds thedoneclass only whenitem.boughtis true, a handy form of:for classes.
defineProps and defineEmits don't need importing; <script setup> provides them.
The whole example
Put together, the parent imports the child, passes each item down as a prop and listens for its event. Every idea from this post appears once:
<!-- ShoppingList.vue -->
<script setup>
import { ref, computed } from 'vue'
import ShoppingItem from './ShoppingItem.vue'
const newItem = ref('')
const items = ref([
{ id: 1, name: 'Bread', bought: false },
{ id: 2, name: 'Coffee', bought: true },
])
const leftToBuy = computed(() => items.value.filter((item) => !item.bought).length)
function addItem() {
if (!newItem.value) return
items.value.push({ id: Date.now(), name: newItem.value, bought: false })
newItem.value = ''
}
function toggle(item) {
item.bought = !item.bought
}
</script>
<template>
<h1>Shopping list</h1>
<p>{{ leftToBuy }} left to buy</p>
<form @submit.prevent="addItem">
<input v-model="newItem" placeholder="Add something" />
<button :disabled="!newItem">Add</button>
</form>
<p v-if="items.length === 0">Nothing on the list yet.</p>
<ul v-else>
<ShoppingItem
v-for="item in items"
:key="item.id"
:item="item"
@toggle="toggle(item)"
/>
</ul>
</template>
Reading it from top to bottom:
newItemanditemsarerefs, so Vue watches them.leftToBuyiscomputedfromitems.v-modelkeeps the text box andnewItemin sync.@submit.preventrunsaddItemwithout reloading the page, andaddItempushes to the list.:disabledswitches the button off while the box is empty.v-if/v-elseswap the empty message for the list.v-forrenders oneShoppingItemper item, with a:key.:itempasses each item down as a prop, and@togglehandles the event coming back up.
Notice that there isn't a single line that touches the page directly. The functions only change data; the template says what the page looks like for that data; Vue does the rest.
Cheat sheet
| You want to… | Write | Example |
|---|---|---|
| Hold data Vue can watch | ref() | const count = ref(0) |
| Show a value as text | {{ }} | {{ count }} |
| Set an attribute from data | : (v-bind) | :disabled="count > 9" |
| Run code on a user action | @ (v-on) | @click="count++" |
| Keep an input and data in sync | v-model | v-model="name" |
| Show something only sometimes | v-if / v-else | v-if="loggedIn" |
| Repeat an element per item | v-for + :key | v-for="x in list" :key="x.id" |
| Name a value worked out from others | computed() | computed(() => a.value + b.value) |
| Pass data into a child | props | defineProps({ item: Object }) |
| Tell the parent something happened | events | defineEmits(['toggle']) |
What you can safely leave for later
Everything above is enough to build real screens. The rest of Vue is worth learning once one of these problems actually shows up:
- Several pages with their own addresses → Vue Router, the official router.
- Data shared by many distant components → Pinia, the official store library.
- Running code when a component appears or disappears → lifecycle hooks such as
onMounted. - Reusing logic (not markup) between components → composables, plain functions that use
refandcomputed. - Type checking → TypeScript, which
npm init vue@latestcan set up for you.
Each of these builds on the same loop: data in refs, a template that describes the page, and events that change the data. The official quick start is the natural next step once that loop feels familiar.
