ai vue tutorial 2026-08-30

By Xah Lee. Date: .
work in progress

write a tutorial of vue js

ai answer

Vue.js Tutorial (Vue 3)

A practical guide to Vue 3 using the current recommended stack: Vite, Composition API, and <script setup>. Official docs: https://vuejs.org/ .

You should already know HTML, CSS, and basic JavaScript. A downloadable copy of this tutorial is at the end.


1. What Vue is

Vue is a progressive JavaScript framework for user interfaces. It adds two ideas on top of HTML/CSS/JS:

  1. Declarative rendering — you describe the UI from state; Vue updates the DOM when state changes.
  2. Components — reusable pieces that own their template, logic, and styles.

You can drop Vue into one page with a <script> tag, or use it as a full SPA with a build tool, router, and store.

Vue 3 has two API styles. Options API (data, methods, mounted) is fine for simple components. Composition API (ref, computed, onMounted) is the official choice for new apps, especially with TypeScript. This tutorial uses Composition API.


2. How to start

No build step (try it in a file)

<!DOCTYPE html>
<html>
  <head>
    <title>Hello Vue</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  </head>
  <body>
    <div id="app">
      <h1>{{ message }}</h1>
      <button @click="count++">Clicked {{ count }} times</button>
    </div>
    <script>
      const { createApp, ref } = Vue
      createApp({
        setup() {
          const message = ref('Hello Vue')
          const count = ref(0)
          return { message, count }
        }
      }).mount('#app')
    </script>
  </body>
</html>

{{ message }} interpolates text. @click listens for clicks. ref() is reactive state.

Real project (recommended)

npm create vue@latest
cd your-project-name
npm install
npm run dev

Vite serves the app (usually http://localhost:5173). Use VS Code plus the Vue - Official (Volar) extension.

npm run dev      # hot-reload dev server
npm run build    # production build → dist/
npm run preview  # serve the production build

3. Single-File Components

A .vue file is one component:

<script setup>
import { ref } from 'vue'
const title = ref('My first component')
</script>

<template>
  <h1>{{ title }}</h1>
</template>

<style scoped>
h1 { color: #42b883; }
</style>

Bindings in <script setup> are available in the template automatically. scoped CSS applies only to that component.

main.js mounts the root:

import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

4. Reactivity

ref — any value

In script, use .value. In template, Vue unwraps it.

<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

reactive — objects

<script setup>
import { reactive } from 'vue'
const user = reactive({ name: 'Ada', age: 36 })
</script>

<template>
  <p>{{ user.name }} is {{ user.age }}</p>
</template>

computed — derived, cached state

<script setup>
import { ref, computed } from 'vue'
const first = ref('Ada')
const last = ref('Lovelace')
const fullName = computed(() => `${first.value} ${last.value}`)
</script>

Use computed when you derive a value. Use a method when you do something (click, submit, fetch).


5. Template syntax

<!-- bind attributes -->
<img :src="imageUrl" :alt="name" />
<button :disabled="saving">Save</button>

<!-- conditionals -->
<p v-if="ok">All good</p>
<p v-else>Error</p>
<p v-show="open">Toggled with CSS</p>

<!-- lists — always use a stable :key -->
<li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>

<!-- events -->
<button @click="save">Save</button>
<form @submit.prevent="onSubmit">…</form>
<input @keyup.enter="submit" />

: is v-bind. @ is v-on.
v-if adds/removes DOM. v-show toggles display.


6. Forms: v-model

Two-way binding:

<script setup>
import { ref } from 'vue'
const text = ref('')
const agreed = ref(false)
</script>

<template>
  <input v-model="text" placeholder="Name" />
  <label><input type="checkbox" v-model="agreed" /> I agree</label>
</template>

On a child component (Vue 3.4+):

<script setup>
const model = defineModel()
</script>
<template>
  <input v-model="model" />
</template>

7. Components: props down, events up

Child (HelloName.vue)

<script setup>
const props = defineProps({
  name: { type: String, required: true }
})
const emit = defineEmits(['greet'])

function sayHi() {
  emit('greet', `Hello, ${props.name}`)
}
</script>

<template>
  <button @click="sayHi">Greet {{ name }}</button>
</template>

Parent

<script setup>
import HelloName from './HelloName.vue'
function onGreet(msg) { alert(msg) }
</script>

<template>
  <HelloName name="Ada" @greet="onGreet" />
</template>

Do not mutate props. Emit an event (or use defineModel).

Slots let the parent inject markup:

<!-- Card.vue -->
<template>
  <section>
    <header><slot name="header" /></header>
    <slot />
  </section>
</template>
<Card>
  <template #header>Title</template>
  <p>Body</p>
</Card>

8. Lifecycle and watchers

<script setup>
import { onMounted, onUnmounted, watch, watchEffect } from 'vue'

onMounted(() => { /* fetch, measure DOM, attach widgets */ })
onUnmounted(() => { /* clear timers and listeners */ })

watch(query, async (q) => {
  results.value = q ? await search(q) : []
})

watchEffect(() => {
  localStorage.setItem('query', query.value)
})
</script>

9. Fetching data

<script setup>
import { ref, onMounted } from 'vue'

const posts = ref([])
const loading = ref(true)
const error = ref(null)

onMounted(async () => {
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
    if (!res.ok) throw new Error('Request failed')
    posts.value = await res.json()
  } catch (e) {
    error.value = e.message
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <p v-if="loading">Loading…</p>
  <p v-else-if="error">{{ error }}</p>
  <ul v-else>
    <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  </ul>
</template>

10. Composables (reusable logic)

// useCounter.js
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  return { count, increment }
}
<script setup>
import { useCounter } from './useCounter'
const { count, increment } = useCounter(10)
</script>

This is the main reason to use the Composition API: share logic without mixins.


11. Mini app: todos

<script setup>
import { ref, computed } from 'vue'

const newTodo = ref('')
const todos = ref([
  { id: 1, text: 'Read the Vue guide', done: true },
  { id: 2, text: 'Build a todo app', done: false }
])

const remaining = computed(() => todos.value.filter(t => !t.done).length)

function addTodo() {
  const text = newTodo.value.trim()
  if (!text) return
  todos.value.push({ id: Date.now(), text, done: false })
  newTodo.value = ''
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

<template>
  <h1>Todos</h1>
  <p>{{ remaining }} left</p>
  <form @submit.prevent="addTodo">
    <input v-model="newTodo" placeholder="What needs doing?" />
    <button type="submit">Add</button>
  </form>
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <label>
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
      </label>
      <button @click="removeTodo(todo.id)"</button>
    </li>
  </ul>
</template>

That one file uses ref, computed, v-model, v-for, events, and form submit — the everyday toolkit.


12. Router and Pinia (when you need them)

Vue Router — pages without full reloads:

import { createRouter, createWebHistory } from 'vue-router'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('./pages/Home.vue') },
    { path: '/about', component: () => import('./pages/About.vue') }
  ]
})
createApp(App).use(router).mount('#app')
<template>
  <RouterLink to="/">Home</RouterLink>
  <RouterView />
</template>

Pinia — shared state across distant components:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubled = computed(() => count.value * 2)
  function increment() { count.value++ }
  return { count, doubled, increment }
})

Keep local UI state in the component. Use a store when several screens need the same data. Pinia replaced Vuex as the official recommendation.


13. Common mistakes

  1. Forgetting .value in script (count++ on a ref does nothing useful).
  2. Mutating props instead of emitting an event.
  3. Missing a stable :key on v-for.
  4. Using a method where computed belongs.
  5. Replacing a reactive object (state = { ... } drops the proxy). Mutate fields, or use a ref.

14. What to do next

  1. Rebuild the todo app from memory.
  2. Add All / Active / Completed filters.
  3. Persist todos with watch + localStorage.
  4. Split each item into a child component.
  5. Add Vue Router and a Pinia store.
Resource Link
Interactive official tutorial https://vuejs.org/tutorial/
Full guide vuejs.org/guide
Router router.vuejs.org
Pinia pinia.vuejs.org
Full-stack / SSR Nuxt

Vue stays small on purpose: templates, reactivity, components. Router, store, and meta-frameworks are opt-in.


Full markdown version (same content, easier to save or print):

If you want a follow-up, I can turn this into a step-by-step project (todo app with router + Pinia), a TypeScript version, or an Options API companion.