Vue 3 is a JavaScript framework in which you describe the UI with an HTML template and state changes reach the DOM automatically. A new app is created with npm create vue@latest, components live in .vue files with a <script setup> block, state is declared with ref and derived values with computed. Data flows into a child component through props and back through emit events.
After this you will be able to
- Create a Vue 3 project with TypeScript and Vitest using
create-vueand build it - Write a single-file component with
<script setup>,refandcomputed, and explain when.valueis needed - Render a list with
v-forandkey, bind a field withv-modeland handle form submission with@submit.prevent - Pass data to a child component through props and get changes back through
emitwithout mutating props
Know beforehand
Verified with
Vue 3.5create-vue 3.24TypeScript 6.0 + vue-tsc 3.3Vite 8.3Vitest 4.1 + @vue/test-utils 2.5Node.js 24 LTS
Checked against docs: October 11, 2026
Contents
Vue and where it sits next to React
Vue is a framework for user interfaces built on top of standard HTML, CSS and JavaScript. The docs highlight two foundations. Declarative rendering: in a template you describe what the UI should look like for a given state, not which DOM nodes to change. Reactivity: Vue tracks state changes itself and updates the affected parts of the page. In the DOM and events lesson we built a task list by hand — finding elements, creating nodes, attaching handlers. Here we build the same list, and Vue does that work.
Vue calls itself a progressive framework: you can add it to an existing page with a single script or build a full single-page application with it. Components come in two styles — the Options API (an object with data and methods) and the Composition API (imported functions such as ref and computed). For full applications the docs recommend the Composition API together with single-file components, and that is what we use here.
Markup
- Vue 3
- An HTML template with directives (
v-if,v-for) - React
- JSX — markup inside JavaScript
State
- Vue 3
ref(), changed through.value- React
useState(), changed through a setter
Updates
- Vue 3
- Vue tracks which data the template read
- React
- The component is called again when state changes
Form field
- Vue 3
v-model— two-way binding- React
value+onChange
Child → parent
- Vue 3
- An event via
emit - React
- A callback function in props
Creating a project
The official way to start is create-vue, which scaffolds a Vite-based project. The docs require Node.js ^22.18.0 or >=24.12.0. The tool asks which features to add: for this lesson choose TypeScript and Vitest; the rest (Vue Router, Pinia, ESLint) can wait.
npm create vue@latest vue-tasks
cd vue-tasks
npm install
npm run devYou can pass the same answers as flags — handy when you want an empty project without example code:
npm create vue@latest vue-tasks -- --ts --vitest --bareThe project comes with two useful scripts. Tests run on Vitest, which the Vue docs recommend for Vite-based projects. The build first checks types with vue-tsc — a tsc that understands .vue files — and only then bundles the app.
npm run test:unit -- --run # Vitest: tests once, without watch mode
npm run build # vue-tsc type check + vite buildA single-file component: template, script setup, style
A Single-File Component (SFC) is a .vue file that holds one component's logic, markup and styles. The <script setup> block is a compact form of the Composition API: everything declared at its top level (variables, functions, imports) is available in the template directly. lang="ts" enables TypeScript.
<script setup lang="ts">
import { computed, ref } from 'vue'
const count = ref(0)
const isEven = computed(() => count.value % 2 === 0)
function increment() {
count.value++ // in <script>, a ref is read and written through .value
}
</script>
<template>
<!-- in the template, refs are unwrapped automatically: no .value -->
<button type="button" @click="increment">Clicked {{ count }} times</button>
<p>{{ isEven ? 'Even' : 'Odd' }}</p>
</template><template>— HTML with expressions in double curly braces{{ }}and directives.@clickis shorthand forv-on:click.<script setup lang="ts">— the component code; nothing needs to be exported.<style scoped>— styles that apply only to this component (seeTaskItem.vuebelow).
Reactivity: ref, reactive and computed
A plain JavaScript variable cannot report that it has changed. So Vue wraps the value in a reference object: ref(0) returns an object with a .value property. Vue records a read of .value as a dependency and a write as a reason to update everything that depends on it. In <script> you access the value through .value; in the template the ref is unwrapped automatically, which is why you write just {{ count }} there.
ref works with any value, including arrays and objects: nested data becomes reactive too, so tasks.value.push(...) and task.done = true update the page. There is also reactive(), which makes the object itself reactive, but the docs list its limitations: objects only, not primitives; the object cannot be replaced as a whole without losing the connection; destructured properties lose reactivity. That is why the docs recommend ref as the primary way to declare state.
computed describes a value derived from other state. Unlike a plain function called in the template, its result is cached and recalculated only when its reactive dependencies change. The function inside computed must be free of side effects — no requests and no changes to other state, only the calculation.
What it is
ref- Stored state
computed- A derived value
- Plain function
- An action or calculation
Writable
ref- Yes, through
.value computed- No (read-only by default)
- Plain function
- —
Recalculation
ref- —
computed- Only when dependencies change
- Plain function
- On every call, including every re-render
Example
reftasks,newTitlecomputedvisibleTasks,remaining- Plain function
addTask,toggle
Directives: v-if, v-for, v-model, v-on
Directives are special template attributes with the v- prefix. They connect markup to state:
v-if / v-else
- What it does
- Adds an element to the DOM or removes it
- Example
<ul v-if="visibleTasks.length">
v-show
- What it does
- Hides an element with CSS, keeping it in the DOM
- Example
- For frequent toggling
v-for
- What it does
- Repeats an element for each item of an array
- Example
v-for="task in visibleTasks" :key="task.id"
v-bind (:)
- What it does
- Binds an attribute or prop to an expression
- Example
:task="task",:checked="task.done"
v-on (@)
- What it does
- Listens to an event
- Example
@click="increment",@submit.prevent="addTask"
v-model
- What it does
- Two-way binding between a form field and state
- Example
v-model.trim="newTitle"
- Always give
v-fora:keywith a unique id: Vue uses it to tell items apart and reuse them when they are reordered. - Do not put
v-ifandv-foron the same element:v-ifruns first and cannot see the loop variable. The docs suggest filtering the list withcomputedinstead — that is whatvisibleTasksdoes. v-modelignores thevalue,checkedandselectedattributes in markup: the source of truth is the JavaScript state, so set the initial value there.- The
.trimmodifier trims whitespace,.numberconverts input to a number, and.preventon an event callsevent.preventDefault()— the form does not reload the page.
Props and emits: data down, events up
The task component receives data from its parent through props and reports user actions with events. Props are declared with the defineProps macro and events with defineEmits; both are available in <script setup> without an import. With TypeScript you can describe them with types: defineEmits<{ toggle: [id: number] }>() means "a toggle event with one argument, a number".
export type Task = { id: number; title: string; done: boolean }
export type Filter = 'all' | 'active' | 'done'<script setup lang="ts">
import type { Task } from '../types'
const props = defineProps<{ task: Task }>()
const emit = defineEmits<{
toggle: [id: number]
remove: [id: number]
}>()
</script>
<template>
<li :class="{ done: props.task.done }">
<label>
<input type="checkbox" :checked="props.task.done" @change="emit('toggle', props.task.id)" />
{{ props.task.title }}
</label>
<button type="button" :aria-label="`Delete ${props.task.title}`" @click="emit('remove', props.task.id)">
×
</button>
</li>
</template>
<style scoped>
.done {
color: #888;
text-decoration: line-through;
}
</style>Data in Vue flows one way: parent → child. Props are read-only — type checking alone will not let you assign a new value:
$ npx vue-tsc --build
src/components/MutateProp.vue(7,9): error TS2540: Cannot assign to 'task' because it is a read-only property.props.task = { … }Practice: a task list in Vue
Let's put it all together. App.vue owns the state: the task array, the new-task text and the filter. The list is filtered with computed, each task is a TaskItem component, and the parent handles its events.
<script setup lang="ts">
import { computed, ref } from 'vue'
import TaskItem from './components/TaskItem.vue'
import type { Filter, Task } from './types'
const tasks = ref<Task[]>([
{ id: 1, title: 'Read the Vue guide', done: true },
{ id: 2, title: 'Build a task list', done: false },
])
const newTitle = ref('')
const filter = ref<Filter>('all')
let nextId = 3
const visibleTasks = computed(() => {
if (filter.value === 'active') return tasks.value.filter((t) => !t.done)
if (filter.value === 'done') return tasks.value.filter((t) => t.done)
return tasks.value
})
const remaining = computed(() => tasks.value.filter((t) => !t.done).length)
function addTask() {
if (!newTitle.value) return
tasks.value.push({ id: nextId++, title: newTitle.value, done: false })
newTitle.value = ''
}
function toggle(id: number) {
const task = tasks.value.find((t) => t.id === id)
if (task) task.done = !task.done
}
function remove(id: number) {
tasks.value = tasks.value.filter((t) => t.id !== id)
}
</script>
<template>
<main>
<h1>Tasks</h1>
<form @submit.prevent="addTask">
<label for="new-task">New task</label>
<input id="new-task" v-model.trim="newTitle" autocomplete="off" />
<button type="submit">Add</button>
</form>
<label for="filter">Show</label>
<select id="filter" v-model="filter">
<option value="all">All</option>
<option value="active">Active</option>
<option value="done">Done</option>
</select>
<ul v-if="visibleTasks.length">
<TaskItem v-for="task in visibleTasks" :key="task.id" :task="task" @toggle="toggle" @remove="remove" />
</ul>
<p v-else>Nothing here yet.</p>
<p>Remaining: {{ remaining }}</p>
</main>
</template>Let's check the behaviour with tests. @vue/test-utils mounts a component, setValue types into a field, trigger fires an event, and emitted() shows which events the component sent. The last test checks the key rule: TaskItem reports a change but does not modify the object from props.
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
import TaskItem from '../components/TaskItem.vue'
describe('App', () => {
it('adds a trimmed task and ignores an empty one', async () => {
const wrapper = mount(App)
await wrapper.get('#new-task').setValue(' Write tests ')
await wrapper.get('form').trigger('submit')
expect(wrapper.findAll('li').map((li) => li.text())).toContain('Write tests ×')
expect((wrapper.get('#new-task').element as HTMLInputElement).value).toBe('')
await wrapper.get('form').trigger('submit')
expect(wrapper.findAll('li')).toHaveLength(3)
})
it('toggles, filters and removes through child events', async () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('Remaining: 1')
await wrapper.findAll('input[type="checkbox"]')[1]!.setValue(true)
expect(wrapper.text()).toContain('Remaining: 0')
await wrapper.get('#filter').setValue('active')
expect(wrapper.text()).toContain('Nothing here yet.')
await wrapper.get('#filter').setValue('all')
await wrapper.get('button[aria-label="Delete Read the Vue guide"]').trigger('click')
expect(wrapper.findAll('li')).toHaveLength(1)
})
})
describe('TaskItem', () => {
it('emits events with the task id instead of mutating the prop', async () => {
const task = { id: 7, title: 'Call the client', done: false }
const wrapper = mount(TaskItem, { props: { task } })
await wrapper.get('input').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('toggle')).toEqual([[7]])
expect(wrapper.emitted('remove')).toEqual([[7]])
expect(task.done).toBe(false)
})
})$ npx vitest run --reporter=verbose
✓ src/__tests__/CounterButton.spec.ts > updates the DOM when the ref changes
✓ src/__tests__/App.spec.ts > App > adds a trimmed task and ignores an empty one
✓ src/__tests__/App.spec.ts > App > toggles, filters and removes through child events
✓ src/__tests__/App.spec.ts > TaskItem > emits events with the task id instead of mutating the prop
Test Files 2 passed (2)
Tests 4 passed (4)npm run build with the vue-tsc check passes without errorsNext, take a closer look at component testing and TypeScript in templates, and for a multi-page app add Vue Router, which create-vue offers when you create a project.
How this was verified5 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.