How to buildOctober 11, 20267 min read
Frontend DevelopmentStep 14 of 18

Vue 3 from scratch: components and reactivity

In short

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.

TutorialLevel: BeginnerVuePractice: ~45–90 min

After this you will be able to

  • Create a Vue 3 project with TypeScript and Vitest using create-vue and build it
  • Write a single-file component with <script setup>, ref and computed, and explain when .value is needed
  • Render a list with v-for and key, bind a field with v-model and handle form submission with @submit.prevent
  • Pass data to a child component through props and get changes back through emit without mutating props

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
  1. 01Vue and where it sits next to React
  2. 02Creating a project
  3. 03A single-file component: template, script setup, style
  4. 04Reactivity: ref, reactive and computed
  5. 05Directives: v-if, v-for, v-model, v-on
  6. 06Props and emits: data down, events up
  7. 07Practice: a task list in Vue

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.

bash
npm create vue@latest vue-tasks
cd vue-tasks
npm install
npm run dev
Creating a project interactively and starting the dev server

You can pass the same answers as flags — handy when you want an empty project without example code:

bash
npm create vue@latest vue-tasks -- --ts --vitest --bare

The 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.

bash
npm run test:unit -- --run   # Vitest: tests once, without watch mode
npm run build                # vue-tsc type check + vite build

A 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.

src/components/CounterButton.vue
<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>
A counter: state, a derived value and a click handler
  • <template> — HTML with expressions in double curly braces {{ }} and directives. @click is shorthand for v-on:click.
  • <script setup lang="ts"> — the component code; nothing needs to be exported.
  • <style scoped> — styles that apply only to this component (see TaskItem.vue below).

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

ref
tasks, newTitle
computed
visibleTasks, 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-for a :key with a unique id: Vue uses it to tell items apart and reuse them when they are reordered.
  • Do not put v-if and v-for on the same element: v-if runs first and cannot see the loop variable. The docs suggest filtering the list with computed instead — that is what visibleTasks does.
  • v-model ignores the value, checked and selected attributes in markup: the source of truth is the JavaScript state, so set the initial value there.
  • The .trim modifier trims whitespace, .number converts input to a number, and .prevent on an event calls event.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".

src/types.ts
export type Task = { id: number; title: string; done: boolean }
export type Filter = 'all' | 'active' | 'done'
src/components/TaskItem.vue
<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>
A task: shows data from props and only tells the parent what should change

Data in Vue flows one way: parent → child. Props are read-only — type checking alone will not let you assign a new value:

terminal
$ npx vue-tsc --build
src/components/MutateProp.vue(7,9): error TS2540: Cannot assign to 'task' because it is a read-only property.
Real vue-tsc output for a component with 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.

src/App.vue
<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>
The task list: add, check off, delete and filter

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.

src/__tests__/App.spec.ts
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)
  })
})
terminal
$ 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)
Real Vitest output (timings omitted); npm run build with the vue-tsc check passes without errors

Next, 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.