skip to content

vue — Progressive JavaScript Framework

Package-level reference for Vue 3 — Composition API, reactivity, single-file components, the Vue 2 → 3 migration, and the broader ecosystem (Pinia, Vue Router, Nuxt).

10 min read 21 snippets deep dive

vue#

What it is#

vue is the Vue.js framework — a progressive UI framework built around fine-grained reactivity, single-file components (.vue), and template-first authoring. Vue 3 (the current line) compiles templates to vanilla JavaScript, uses Proxy-based reactivity, and exposes the Composition API for hook-style composition.

Reach for Vue when you want React-grade interactivity with a less ceremonial mental model (templates and reactivity instead of JSX and reconciliation), an opinionated official toolchain (Vite + Vue Router + Pinia + Nuxt), and a less-fragmented ecosystem. Reach for React if your team is already there or you need React Native; reach for Svelte if compile-time reactivity matters more.

Install#

For a new project, the official scaffolder is the path of least resistance.

npm create vue@latest my-app

Output: interactive prompts (TypeScript, Vue Router, Pinia, Vitest, Playwright, ESLint); writes a Vite + Vue 3 project.

For an existing project:

npm install vue

Output: added vue to dependencies

pnpm add vue

Output: added 1 package

yarn add vue

Output: added vue

bun add vue

Output: installed vue

To compile .vue files you also need @vitejs/plugin-vue (or vue-loader for Webpack).

Versioning & Node support#

Current line is vue@3.x (released 2020). Vue 2 hit end-of-life at the end of 2023.

  • Node 18+ for tooling (Vite, vue-tsc); browser support tracks evergreen browsers.
  • Dual ESM/CJS; ESM is the default for new projects.
  • TypeScript types bundled.
  • vue@3 minor releases are additive; patch versions are stable. Major version bumps occur only every several years.
  • The @vue/composition-api polyfill that lived in the Vue-2 era is obsolete in Vue 3.

Package metadata#

Peer dependencies & extras#

vue itself has no peer deps. The ecosystem packages do.

  • @vitejs/plugin-vue.vue SFC compilation for Vite
  • vue-tsc — type-check .vue files in CI
  • vue-router — official router
  • pinia — official state management (Vuex’s successor)
  • nuxt — full-stack Vue framework
  • @vueuse/core — composition utilities (analog to react-use)
  • vitest + @vue/test-utils — testing

Alternatives#

PackageTrade-off
reactLarger ecosystem, JSX instead of templates, less batteries-included.
svelteCompile-to-vanilla-JS; smaller runtime. Different file format.
solid-jsJSX with fine-grained reactivity (no virtual DOM).
preact~3 KB React-compatible.
litWeb-components-based. Standards-aligned, smaller community.
alpineMinimal in-HTML reactivity for sprinkles, not full apps.

Real-world recipes#

Composition API single-file component#

<script setup lang="ts">
import { ref, computed } from "vue";

const count = ref(0);
const double = computed(() => count.value * 2);
function increment() { count.value++; }
</script>

<template>
  <button @click="increment">{{ count }} (double: {{ double }})</button>
</template>

Output: button shows count and computed double; clicking increments — Vue tracks the ref and re-renders only what depends on it.

Reactive object with reactive#

<script setup lang="ts">
import { reactive } from "vue";

const state = reactive({ items: [] as string[], query: "" });
function add() {
  state.items.push(state.query);
  state.query = "";
}
</script>

<template>
  <input v-model="state.query" />
  <button @click="add">Add</button>
  <ul><li v-for="i in state.items" :key="i">{{ i }}</li></ul>
</template>

Output: typing and clicking add updates the reactive object; the list re-renders.

watch for side effects#

<script setup lang="ts">
import { ref, watch } from "vue";

const query = ref("");
const results = ref<string[]>([]);

watch(query, async (newQ) => {
  if (!newQ) return;
  const res = await fetch(`/api/search?q=${encodeURIComponent(newQ)}`);
  results.value = await res.json();
});
</script>

<template>
  <input v-model="query" placeholder="Search…" />
  <ul><li v-for="r in results" :key="r">{{ r }}</li></ul>
</template>

Output: typing into the input fetches search results and renders them; watch runs whenever query changes.

Pinia store#

// stores/counter.ts
import { defineStore } from "pinia";

export const useCounter = defineStore("counter", {
  state: () => ({ count: 0 }),
  getters: { double: (state) => state.count * 2 },
  actions: { increment() { this.count++; } },
});
<script setup lang="ts">
import { useCounter } from "./stores/counter";
const counter = useCounter();
</script>

<template>
  <button @click="counter.increment">{{ counter.count }} ({{ counter.double }})</button>
</template>

Output: store is shared across components; the button updates the global count.

Vue Router with typed routes#

// router.ts
import { createRouter, createWebHistory } from "vue-router";
import Home from "./views/Home.vue";

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/", component: Home },
    { path: "/posts/:id", component: () => import("./views/Post.vue") },
  ],
});
// main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";

createApp(App).use(router).mount("#app");

Output: dynamic imports lazy-load route components; <router-view /> in App.vue renders the current route.

provide / inject for tree-wide dependencies#

<!-- App.vue -->
<script setup lang="ts">
import { provide, ref } from "vue";
const theme = ref<"light" | "dark">("light");
provide("theme", theme);
</script>
<!-- Child.vue -->
<script setup lang="ts">
import { inject, type Ref } from "vue";
const theme = inject<Ref<"light" | "dark">>("theme");
</script>

<template>
  <p>Current theme: {{ theme }}</p>
</template>

Output: child reads the parent-provided theme without prop drilling; updating the parent ref propagates.

defineProps and defineEmits#

<script setup lang="ts">
const props = defineProps<{ label: string; disabled?: boolean }>();
const emit = defineEmits<{ click: [id: string] }>();
</script>

<template>
  <button :disabled="props.disabled" @click="emit('click', 'btn-1')">
    {{ props.label }}
  </button>
</template>

Output: strongly-typed props and events; parent gets autocomplete for <MyButton :label="..." @click="..." />.

Production deployment#

Vue’s deployment mirrors any Vite-built SPA or SSR app.

  • Vite SPA. vite build emits dist/. Host on any static host (Netlify, Cloudflare Pages, S3 + CloudFront, Nginx). Configure SPA fallback to index.html.
  • Nuxt SSR. nuxt build then node .output/server/index.mjs. Or nuxt build --preset cloudflare / vercel / node-server to target a specific platform.
  • Vue + Vite SSR (manual). Vite has an SSR build mode; write a Node entry that calls renderToString from vue/server-renderer. Most teams prefer Nuxt instead.
  • Static (SSG). Nuxt + nuxt generate produces a static site. VitePress for docs.
  • Edge runtimes. Nuxt’s Cloudflare and Vercel Edge presets ship a Workers-compatible bundle.
nuxt build --preset cloudflare_pages

Output: writes a .output/public/ directory ready for wrangler pages deploy.

Performance tuning#

  • Composition API + <script setup> is the lowest-overhead authoring style. Avoid the Options API for new code unless team preference dictates.
  • shallowRef / shallowReactive for large objects you mutate atomically — Vue skips deep proxy tracking.
  • v-memo memoises a list item’s render by a dependency array. Useful for huge static lists.
  • <KeepAlive> caches dynamic component instances between mounts.
  • Bundle splitting. Use dynamic import() in routes (component: () => import('./X.vue')) so each route ships its own chunk.
  • Server components and partial hydration in Nuxt 3+ (<NuxtIsland>) for islands-style apps.
  • v-once renders a subtree once and never updates — micro-optimisation for truly static blocks.
  • Avoid creating refs in render functions. ref allocations belong in setup, not in templates or computed callbacks.

Version migration guide#

Vue 2’s end of life was end of 2023. Most active codebases are on 3 already.

Vue 2 → Vue 3 (the big one)#

Before (Vue 2 Options API):

export default {
  data() { return { count: 0 }; },
  computed: { double() { return this.count * 2; } },
  methods: { increment() { this.count++; } },
};

After (Vue 3 Composition API):

<script setup>
import { ref, computed } from "vue";
const count = ref(0);
const double = computed(() => count.value * 2);
function increment() { count.value++; }
</script>

Output: same component behaviour; Vue 3 syntax is more amenable to TypeScript and tree-shaking.

Key Vue 2 → 3 breaks:

AreaVue 2Vue 3
App bootstrapnew Vue({ el, render })createApp(App).mount('#app')
ReactivityObject.defineProperty (cannot detect new keys)Proxy (detects everything)
Filters`{{ valuefilter }}`
Event API$on / $emit on app instanceRemoved — use external emitter
Slots syntaxslot="name" / slot-scopev-slot:name="props"
v-modelSingle value propMultiple v-models via v-model:fieldName
FragmentsSingle root requiredMultiple root nodes allowed
TeleportPlugin (vue-portal)Built-in <Teleport>

Migration checklist:

  1. Upgrade tooling first — vite + @vitejs/plugin-vue, drop vue-template-compiler.
  2. Run the official migration build (@vue/compat) which logs Vue 2 patterns inside a Vue 3 runtime.
  3. Replace removed APIs progressively. Filters → methods. $on → external emitter (mitt).
  4. Audit data() { return { ... } } for non-reactive new properties — Vue 3 handles them automatically.
  5. Replace Vuex with Pinia (recommended path).
  6. Run the test suite continuously during the migration.

Vue 3 minor upgrades#

Vue 3 minor releases are additive. vue@3.4 introduced macros like defineModel; 3.5 added reactivity refinements. Read release notes before bumping.

Security considerations#

  • v-html is XSS. Equivalent to dangerouslySetInnerHTML. Sanitise untrusted input with DOMPurify before binding.
  • Template injection on the server. Server-rendered Vue with user-controlled template strings is template-injection — never compile templates from user input.
  • href and src from user data. :href="userUrl" allows javascript: URIs. Validate protocols.
  • <script> injection via slots. Slots accept any content; if a slot fills with user-controlled DOM, sanitise.
  • Pinia state in SSR. Hydrated state lands in the HTML payload. Filter secrets before serialising.
  • Nuxt route middleware. Server middleware runs in Nitro (the server engine). Validate inputs as you would any HTTP handler.

Testing & CI integration#

Unit test with Vitest + @vue/test-utils#

// Counter.test.ts
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Counter from "./Counter.vue";

describe("Counter", () => {
  it("increments on click", async () => {
    const wrapper = mount(Counter);
    await wrapper.find("button").trigger("click");
    expect(wrapper.text()).toContain("1");
  });
});

Output: test passes; mount returns a wrapper with DOM and instance accessors.

Component playground with Storybook (optional)#

npx storybook@latest init

Output: scaffolds Storybook with Vue 3 framework preset; stories live next to components.

CI pipeline#

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: "npm" }
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check
      - run: npm test -- --run
      - run: npm run build

Ecosystem integrations#

PackageRole
vue-routerOfficial router
piniaOfficial state management
nuxtFull-stack framework
@vueuse/coreComposition utilities (analog to react-use)
@vue/test-utilsComponent testing
vitestTest runner with Vite integration
vue-tscType-check .vue files
unplugin-auto-import / unplugin-vue-componentsAuto-import refs and components
naive-ui / vuetify / element-plus / primevueComponent libraries
vee-validateForms + validation
vitepressVue-flavoured docs static site generator

Troubleshooting common errors#

Cannot find module './Foo.vue' or its corresponding type declarations — missing shims-vue.d.ts or the vue-tsc import path in tsconfig.json. Confirm @vitejs/plugin-vue is configured and "compilerOptions.types": ["vite/client"].

Property "x" was accessed during render but is not defined — referenced a variable in the template that isn’t in scope. With <script setup>, ensure the binding is declared at the top level of the script block.

Hydration mismatch in Nuxt — server and client rendered different HTML. Pin locales, dates, and random values; avoid Date.now() in templates.

v-model not working on custom component — Vue 3 changed the prop/event names from value / input to modelValue / update:modelValue. Update the child component or use the explicit form v-model:something.

Maximum recursive updates exceeded — a watcher mutates the value it watches. Add a guard or use watch with flush: 'post'.

[Vue warn]: <Suspense> slot…<Suspense> requires exactly one async child. Wrap multiple async components in a fragment with a single root, or split into nested Suspense.

When NOT to use this#

  • Native mobile. React Native or Flutter; Vue has no equivalent.
  • Tiny embeddable widget. Preact/Vanilla; Vue 3 runtime is ~30 KB gzipped.
  • Team already invested in React. The cost of context-switching usually outweighs the benefit.
  • Existing AngularJS / Backbone codebase that you only want to incrementally modernise. htmx or alpine.js may slot in with less churn.
  • Content-heavy mostly-static site. Astro / Eleventy will be smaller and faster.

See also#