Skip to main content

Vue.js: The Few Ideas That Do Most of the Work

· 12 min read
Pere Pages
Software Engineer
Flat illustration on off-white paper: a stack of indigo, violet and green cards linked by a thin line to a glowing green pill, which is wired to a browser-window wireframe whose highlighted row glows the same green

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 .value out: 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:

DirectiveWhen false, the element is…Use it when…
v-ifremoved from the pagethe condition rarely changes, so the content is often never rendered at all
v-showstill there, but hiddenthe 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:

computedPlain function
Runs when…the data it depends on changesevery time the page re-renders
Remembers its last resultyesno
Good forvalues derived from your dataactions, 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>
  • defineProps declares what the component accepts. The template can then use item directly. See the props guide.
  • defineEmits declares which events it can send, and $emit('toggle') sends one. See the component events guide.
  • :class="{ done: item.bought }" adds the done class only when item.bought is 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:

  1. newItem and items are refs, so Vue watches them.
  2. leftToBuy is computed from items.
  3. v-model keeps the text box and newItem in sync.
  4. @submit.prevent runs addItem without reloading the page, and addItem pushes to the list.
  5. :disabled switches the button off while the box is empty.
  6. v-if / v-else swap the empty message for the list.
  7. v-for renders one ShoppingItem per item, with a :key.
  8. :item passes each item down as a prop, and @toggle handles 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…WriteExample
Hold data Vue can watchref()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 syncv-modelv-model="name"
Show something only sometimesv-if / v-elsev-if="loggedIn"
Repeat an element per itemv-for + :keyv-for="x in list" :key="x.id"
Name a value worked out from otherscomputed()computed(() => a.value + b.value)
Pass data into a childpropsdefineProps({ item: Object })
Tell the parent something happenedeventsdefineEmits(['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 ref and computed.
  • Type checking → TypeScript, which npm init vue@latest can 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.