Skip to content
Behind the scenes

2025 in review: what learners got stuck on

No charts in this one, only the pattern: five Vue ideas the question bank keeps coming back to, each with a one-sentence fix worth taking into January.

vuejs.school team30 Dec 20256 min readVue 3.5 · Nuxt 4.2

Your quiz answers stay in your browser, so this post has no tally of wrong answers behind it. It reads the question bank instead. Every question in the bank is tagged with the concepts it tests, and every wrong option carries a note on the mistaken belief that leads someone to pick it. Read those notes side by side and one pattern runs through them: the hard part is rarely syntax. It is the places where Vue does something for you in one spot and not in another, so your mental model has to hold both.

Five of those places stand out. Each comes with the one sentence that fixes it and the posts from this year that go further.

Losing reactivity with .value and destructuring

The first theme is the oldest. A ref is a box: an object whose .value property holds the value. Vue opens the box for you in templates, so {{ count }} works. Your script gets no such help: if (count > 3) compares an object with a number, and const n = count.value copies a number that will never update. The rule is simple to state and easy to forget under pressure, because half the time Vue hides the box and half the time it does not.

Destructuring is the same confusion in a different shape. Pulling a property off a reactive() object gives you the plain value at that moment:

const state = reactive({ count: 0 })
const { count } = state // a number, frozen at 0
const { count } = toRefs(state) // a ref that follows state.count

What made this harder in 2025 is that destructuring props now does work, because the Vue 3.5 compiler rewrites const { title } = defineProps<...>() into a reactive access. So props yes, reactive() no, and the two are easy to mix up when both sit in one component. The sentence to keep: a ref is a box, the template opens it for you and your script does not. The January post on ref() and reactive() and the February deep dive on props destructure cover the rest.

storeToRefs skips your actions

The second theme is one specific line. Pinia's storeToRefs() turns a store's state and getters into refs, so that you can destructure them without losing reactivity. It skips actions on purpose: an action is a plain function, and wrapping it in a ref would make no sense. With cart taken from useCartStore(), the result is a line that looks right and fails at the first call:

const { items, total, add } = storeToRefs(cart)
add('Sticker') // TypeError: add is not a function
const { items, total } = storeToRefs(cart)
const { add } = cart

Actions can be destructured straight from the store, because Pinia binds them to it. The sentence: storeToRefs for state and getters, the store itself for actions. The May deep dive on how a Pinia store is put together explains why getters become refs and actions do not, and the August post on stores that use other stores shows the pattern inside a store.

Choosing between computed and watch

The third theme is a choice rather than a trap. Both computed and watch react to changes, so when a value depends on two other values, a lot of people reach for a watcher that assigns the result to a ref:

const total = ref(0)
watch([price, qty], () => { total.value = price.value * qty.value })

It works, mostly. But a watcher runs only when its sources change, so until price or qty changes, the page shows the starting 0. And two places now hold the same truth. A computed value has neither problem:

const total = computed(() => price.value * qty.value)

The sentence: computed for a value you can calculate, watch for a thing you must do, such as saving, logging or talking to the network. The February decision guide gives the long form. The April post on watchers covers the follow-up mistakes, among them passing state.count to watch instead of a getter, which hands it a number and watches nothing.

State that leaks between visitors on the server

The fourth theme only appears once an app renders on the server, which is why it surprises people who learned Vue in a single-page app. A ref declared at the top of a composable file is created once, when the module loads. In the browser that module belongs to one person. On a Nuxt server it belongs to the process, and every request shares it, so a visitor can be shown the previous visitor's user object.

app/composables/useUser.tsBreaks
const user = ref<User | null>(null) // module scope: one for the whole server

export function useUser() {
  return user
}
app/composables/useUser.tsWorks
export function useUser() {
  return useState<User | null>('user', () => null)
}

useState stores the value on the Nuxt app instance, which is created per request, and sends it to the client in the payload so hydration matches. A Pinia store does the same through the Nuxt module. The sentence: on the server a module-level ref is shared by every visitor; per-request state lives in useState or in a store. The December post on useState, ref and Pinia in a Nuxt app walks through the decision.

When useFetch runs, and when it runs again

The fifth theme is timing. People expect useFetch to behave like a fetch call placed where it is written, running every time the component renders or every time a variable it mentions changes. It does neither.

It runs once, during setup. For a page rendered on the server, that happens on the server, and the result travels to the client in the payload so the browser does not fetch again. After that it runs again only when something it watches changes, or when you call refresh(). A URL built from a plain string is fixed at setup:

const page = ref(1)
const { data } = await useFetch(`/api/posts?page=${page.value}`) // the string never changes
const page = ref(1)
const { data } = await useFetch('/api/posts', { query: { page } }) // a ref: refetches when page changes

The sentence: useFetch runs once in setup, and again only when something it watches changes or you call refresh(). The April post on the reworked async data layer in Nuxt 3.17, the July post on the data fetching changes in Nuxt 4, and the October post on abort signals in Nuxt 4.2 each add a layer; read them in that order.

What changed around Vue in 2025

Vue itself stayed on 3.5 all year, and that stability is why the themes above are about understanding rather than migration. Around it, a lot moved:

  • Pinia 3 arrived in February and dropped Vue 2.
  • Vite 7 landed in June, with a higher Node requirement (20.19 or 22.12) and Baseline browser targets.
  • Nuxt 4 shipped on 15 July with the app/ directory and the data fetching changes, followed by 4.1 in September and 4.2 in October.
  • Vitest 4 made Browser Mode stable on 22 October, the same day VueUse 14 set Vue 3.5 as its minimum.
  • Vite 8, with Rolldown as its bundler, has been in beta since 3 December.
  • Vue 3.6.0-beta.1 was published on 23 December. Vapor Mode and the new reactivity system are now in a beta, not an alpha, which means the APIs are settling. It is still not something to put in production.

Most of those releases have a release-notes post from this year that says what the change asks of an existing app.

What the school adds in 2026

The shape of a lesson is not changing: a short tutorial with a runnable example, then a quiz drawn from the reviewed question bank, with reviews scheduled by spaced repetition so that what you got wrong comes back before you forget it again.

The optional AI instructor keeps its limits too. It can choose which questions to show you and give hints, and it never writes questions. It stays off until you switch it on. You can run it with your own API key for Anthropic, OpenAI, Google Gemini or any OpenAI-compatible server, and then requests go from your browser straight to the provider you chose. With it off, nothing you type in a lesson leaves your browser.

What grows in 2026 is the amount of practice: more lessons, and more questions for the lessons that exist, especially around the five themes above. There, a second and third question from a different angle is what turns a right answer by elimination into a right answer by understanding. The blog continues at two posts a week: release notes when something ships, tutorials and deep dives in between.

Was this helpful?

Keep reading