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.


