vue

One week with Vue

One week with Vue

I should say up front who is writing this, because it colours everything below. I am a backend developer. I like frameworks: the more of the structure someone has already decided for me, the happier I am. And I am not much of a CSS person. I can make a page work. I cannot make it beautiful, and I have made my peace with that.

I have shipped a fair amount of front end anyway, most of it in Angular and React. Angular because it looks like the backend frameworks I already think in - modules, services, dependency injection, a CLI that generates the boring parts. React because it is what everybody uses, and because it is what Next.js is built on. Vue is the one of the big three I had never written a line of.

So I gave it a week: last weekend to learn it, the evenings in between to keep going, and this weekend to finish something and write it down. The something was a small admin page for the flat automation, which has run for two and a half years with no user interface except Alexa. A list of devices and their state, a list of rules with an enabled toggle, a form to edit one rule. Small, but it has real data, a real API and a form, which is where front-end frameworks usually start to show their personality.

Three questions ran through the week, and they are the ones I would ask of any backend framework. How many decisions does it make for me? Does it feel like the way I already structure a service? And how much of my time goes into the parts I am bad at?

These are beginner's notes, dated. Everything here is Vue 3.3, Vite 4.4 and Vitest 0.33, as of the middle of July 2023.

The decisions are already made

npm create vue@latest

That is create-vue, the official scaffolder. It asks a handful of yes-or-no questions - TypeScript, JSX, Vue Router, Pinia, Vitest, an end-to-end tool, ESLint, Prettier - and writes a project that uses exactly what you said yes to. I said yes to all of it except JSX.

The first impression that stuck was that the scaffold is official all the way through. Router, state management, test runner, type checking, the editor extension - each one has a single answer maintained by the core team or close to it. Coming from React, where each of those is a small research project, that felt like a framework in the sense I mean the word: the same sense in which NestJS or ASP.NET Core is one.

The old Vue CLI still exists, but it is in maintenance mode and the documentation no longer points at it. The new default is built on Vite, and that was the most important decision in the whole stack, so it gets its own section further down.

A component is one file

A Vue component is a .vue file with three blocks in it: the template, the script and the styles. This is the device list, more or less as I wrote it on Saturday:

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Device } from '@/api/types'
import { fetchDevices } from '@/api/devices'

const devices = ref<Device[]>([])
const query = ref('')

const visible = computed(() =>
  devices.value.filter((d) => d.name.toLowerCase().includes(query.value.toLowerCase())),
)

onMounted(async () => {
  devices.value = await fetchDevices()
})
</script>

<template>
  <input v-model="query" placeholder="Filter devices" />
  <ul>
    <li v-for="device in visible" :key="device.id" :class="{ off: !device.on }">
      {{ device.name }} <span>{{ device.room }}</span>
    </li>
  </ul>
</template>

<style scoped>
li {
  padding: 0.25rem 0;
}
.off {
  opacity: 0.5;
}
</style>

<script setup> is the Composition API, and it is the style the documentation now leads with. There is an older Options API - data, methods, computed as keys on an object - which still works, and which is where most of the answers on the internet are written. That split is the first real cost I ran into, and I will come back to it.

The template is HTML with directives: v-for, v-if, v-model, : for a bound attribute, @ for an event. If you have written Angular, you already know how to read it; v-for is *ngFor, v-model is [(ngModel)], @click is (click). If you have only written React, it will look like a step backwards for about a day, and then it will look like less typing.

The reactivity is the part that is actually different

In React, a component is a function that runs again on every render. State lives outside it in hooks, and anything derived either gets recomputed each time or goes into useMemo with a dependency array that you maintain by hand. Effects get a dependency array too. Most of the React bugs I have written are one of those arrays being wrong, or a closure holding on to a value from two renders ago.

In Vue, setup runs once. ref and computed create reactive values, and Vue records which of them a computation reads while it runs. When one changes, exactly the things that read it update. There is no dependency array, because the framework already knows the dependencies. visible above is recomputed when devices or query changes, and cached otherwise, and I did not have to say so. To a backend developer that is a familiar shape: a cache that invalidates itself because it knows what it was built from.

I spent a surprising amount of Saturday waiting for the catch. The catch is small but real: a ref is an object, and in script you read and write it through .value. The template unwraps it for you, so you write query in one place and query.value in the other, and for the first few hours I got that wrong in both directions. The other trap is destructuring a reactive object or the props, which gives you plain values that no longer update. Vue 3.3 has an experimental option that makes destructured props stay reactive, which says something about how many people have fallen into it.

What made me stop and think was Angular. Angular 16, released in May, added signals in developer preview: signal(), computed(), effect(). They are a new, fine-grained way to track state, meant eventually to let Angular stop depending on Zone.js to guess when something might have changed. After a week of Vue, signals look very familiar. The framework I trust most for its structure is converging on the model Vue has had since version 3, and React is the one heading somewhere else.

TypeScript got good in 3.3

I write TypeScript on the server, and I want the types in the UI to be the same types the API returns. Vue 3.3 came out in May, and it fixed the thing I would otherwise have complained about most.

Props and emits are declared with types:

import type { Rule } from '@/api/types'

const props = defineProps<{ rule: Rule }>()

const emit = defineEmits<{
  save: [rule: Rule]
  cancel: []
}>()

Until 3.3, the type passed to defineProps had to be written in the same file - the compiler could not follow an import. That meant every component that took a DTO either repeated its shape or fell back to runtime prop declarations. In 3.3 it follows imports, so Rule comes from the same file the API client uses and there is one definition of it. The tuple syntax for defineEmits is also new in 3.3, and it is much nicer than the call-signature overloads it replaces.

The same release added generic components, with a generic attribute on <script setup>, which I used for a table that takes rows of any type. It works, and it reads like TypeScript rather than like a workaround.

The weak spot is that tsc does not understand .vue files. Type checking goes through vue-tsc, and in the editor through Volar, the official VS Code extension pair - "Vue Language Features" and "TypeScript Vue Plugin". They are good, including type errors inside the template, but there is a separate toolchain in the middle of your types, and a build script that runs vue-tsc alongside the bundler. Angular has the same shape with its own compiler, and React has the advantage here: JSX is TypeScript, so there is nothing extra.

Pinia is a service

For state that outlives a component I used Pinia, which has replaced Vuex as the official store. Written the Composition API way, a store is a function that returns refs and functions:

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { Rule } from '@/api/types'
import { fetchRules, saveRule } from '@/api/rules'

export const useRulesStore = defineStore('rules', () => {
  const rules = ref<Rule[]>([])
  const enabled = computed(() => rules.value.filter((r) => r.enabled))

  async function load() {
    rules.value = await fetchRules()
  }

  async function save(rule: Rule) {
    const saved = await saveRule(rule)
    rules.value = rules.value.map((r) => (r.id === saved.id ? saved : r))
  }

  return { rules, enabled, load, save }
})

No actions, reducers, mutations or selectors as separate concepts. It is a singleton with state and methods on it, and any component that calls useRulesStore() gets the same instance. That is an Angular service with an @Injectable({ providedIn: 'root' }) on it, minus the decorator, or a singleton-scoped service in NestJS, and it is the thing I end up building by hand with context in React. Of everything in the week, this was where I felt most at home.

What Vue does not give you is Angular's full kit. There is no HttpClient, no forms module, no dependency-injection container beyond provide and inject. I used fetch and a small wrapper. For a page this size that is the right amount of framework; for a large application with twenty developers I can see why teams pick Angular precisely because the answer to every question is already written down.

The CSS stays where I put it

I did not expect to have an opinion about styles, but I do.

<style scoped> means the CSS in a component applies to that component only. The compiler adds a data attribute to the elements and rewrites the selectors to match it, so a li rule in the device list cannot leak into the rule list. It is the same idea as Angular's default view encapsulation, and it is the thing I miss every time I write React, where the answer is a choice between CSS Modules, a CSS-in-JS library and utility classes, each with its own set of opinions.

For someone like me, the value is scope, in the sense a backend developer means it: the little CSS I write is local, sits next to the markup it styles, and cannot break a page I am not looking at. There is even v-bind() inside the style block, so a colour can come from a reactive value without inline styles. I used it once, for the state dot next to each device, and felt briefly competent.

Sass works by installing sass. There is no loader to configure, which is the first hint of the next section.

Vite is the reason to pay attention

The first thing I noticed was speed. npm run dev starts in well under a second, and I kept checking whether it had actually done anything.

Vite does not bundle your application in development. It serves your source as native ES modules and lets the browser request them one at a time, transforming each file on demand - TypeScript and .vue files included. Dependencies from node_modules are pre-bundled once with esbuild, which is written in Go and very fast. When you save a file, hot module replacement swaps that one module, and the page keeps its state. For production, it bundles with Rollup.

Angular's ng serve and Create React App both sit on webpack, and on any project of size you know the pause between saving a file and seeing it. Vite's start-up time does not grow with the application, because it only does work for the files the page actually requests.

The second thing I noticed was that the configuration is short enough to read in full, and that the parts I care about as a backend developer are in it:

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  server: {
    proxy: {
      '/api': 'http://localhost:3000',
    },
  },
})

The server.proxy block is my favourite line in the project. The front end calls /api/rules; in development Vite forwards that to the API running on another port, so there is no CORS configuration to write and nothing to remove before production. Environment variables are handled carefully too: only those prefixed with VITE_ are exposed to client code through import.meta.env, so a database URL in .env does not end up in a bundle by accident.

The third thing is where it has turned up. Vite 4.4 came out ten days ago, with experimental support for Lightning CSS, but the adoption matters more than the version. Vite was written by Evan You, who also wrote Vue, and it started as Vue tooling, but it has outgrown that. Nuxt 3 is built on it. So are SvelteKit, Astro and SolidStart. Laravel replaced Mix with Vite last summer, which means a large number of PHP developers now use it without thinking of it as a JavaScript decision. Storybook 7 has a first-class Vite builder. npm create vite@latest has templates for React, Preact, Svelte, Lit and plain TypeScript - which matters more since the new React documentation stopped recommending Create React App and left a lot of React projects without an obvious place to start. And Angular 16's new esbuild-based build system, in developer preview, uses Vite for its development server.

That last one surprised me most. The framework with the strongest opinions about its own tooling is adopting the Vue author's development server, under the hood, behind ng serve. I think Vite is going to be larger than Vue. It is already on its way to being the default way JavaScript gets served in development, and every framework that adopts it makes Vue's own tooling better tested and better funded. That is an unusual position for a framework to be in: the rest of the industry is maintaining its build tool.

Vitest is the one I want on the backend

I wrote last summer about Node 18's test runner and why I was keeping Jest. This is the first time I have wanted to leave Jest.

Vitest is a test runner built on Vite. The test for the rule editor:

import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import RuleEditor from '@/components/RuleEditor.vue'

describe('RuleEditor', () => {
  it('emits save with the edited rule', async () => {
    const rule = { id: 'lamps-at-dusk', name: 'Lamps at dusk', enabled: true }
    const wrapper = mount(RuleEditor, { props: { rule } })

    await wrapper.get('input[name="name"]').setValue('Lamps at sunset')
    await wrapper.get('form').trigger('submit')

    expect(wrapper.emitted('save')?.[0]).toEqual([{ ...rule, name: 'Lamps at sunset' }])
  })
})

If that looks like Jest, that is deliberate. describe, it, expect, mocks through vi.fn() and vi.mock() - the API is Jest's, closely enough that most suites move across with a find-and-replace on imports.

The difference is what is underneath. Vitest reads the same vite.config.ts as the application. The @ alias, the Vue plugin, the TypeScript transform - all of it is already set up, because it is the same pipeline. There is no ts-jest, no Babel config, no moduleNameMapper duplicating the aliases, and no separate fight with ES modules; Jest's ESM support still needs an experimental Node flag, and Vitest is ESM from the start. create-vue generates a vitest.config.ts that is a few lines of mergeConfig on top of the Vite config, with jsdom as the environment.

And the watch mode is the development server's trick applied to tests. Vitest knows the module graph, so when I save RuleEditor.vue it reruns the tests that import it and nothing else, fast enough that it feels like the test result is part of the editor. vitest --ui gives a browser view of the same thing. There is also expectTypeOf for asserting on types, which I wrote one test with out of curiosity and will use properly in a library.

Nothing about Vitest is Vue-specific. It works just as well for a Node service with no UI at all, and that is where I intend to try it next - on a TypeScript backend where Jest's transform setup is the most fragile file in the repository. The caution is the version number: 0.33 is pre-1.0, minor releases still carry breaking changes, and I would pin it exactly.

For comparison, Angular's default is still Karma and Jasmine, Karma itself has been deprecated this year, and Angular 16 ships Jest support as experimental. Of the three, Vue is the only one whose default test runner is the one I would have picked myself.

What I did not like

A week is enough to find the rough edges.

There are two ways to write everything. The Options API and the Composition API are both supported, and about half of what I found while searching was written in the one I was not using. The official documentation has a toggle for it, which is thoughtful and also a sign of the problem. For someone who picked a framework to have fewer decisions, this is one decision too many.

Vue 2 still casts a shadow. It reaches end of life on the 31st of December. Plenty of libraries, tutorials and answers are still for Vue 2, and a few popular packages have Vue 3 versions that are newer and thinner than their Vue 2 equivalents. Nothing blocked me, but I checked the version on everything.

I got used to .value, but I did not come to like it.

The world is smaller. For anything I might do with React, someone has already written a library and three blog posts. With Vue it is usually one library, which is often enough, but occasionally it is the Vue 2 one.

Where I landed

Back to the three questions.

Vue makes most of the decisions for me, and makes them once, officially. It feels like the way I structure a service, with stores that are singletons and types that come from the API. And the part I am bad at stays small: <style scoped> meant a week of front-end work with none of the CSS arguments I usually end up losing.

I am not moving anything. The applications I work on are where they are for reasons that have little to do with the view layer. For a large team that wants every question answered in advance, Angular is still the framework I would choose, and I trust it more now that signals are coming. For anything that wants Next.js, it is React. But for the small tools I build around my own backends - admin pages, internal dashboards, the UI for something that has run for two and a half years without one - Vue is now the one I will reach for first.

The bigger takeaway has little to do with Vue: Vite and Vitest are the best JavaScript tooling I have used, and they are spreading well beyond the framework they came from. If you do nothing else, try Vitest on whatever you are already testing with Jest, including the backend.

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

© 2026 Deyan Peev