The order form was six hundred lines and used four mixins. Finding where isValid came from meant opening all four, and two of them defined it. Vue 3 shipped in September with an answer to exactly that, and the answer is easy to misread as a new syntax when it is really a different unit of reuse.
The symptom
export default {
mixins: [validationMixin, currencyMixin, addressMixin, analyticsMixin],
data() {
return { form: {}, submitting: false, errors: {} }
},
computed: {
// isValid is here somewhere. or in one of the four mixins.
// two of them define it. the last one registered wins.
canSubmit() {
return this.isValid && !this.submitting
},
},
// 540 more lines
}
$ grep -rn 'isValid' src/mixins/
src/mixins/validation.js:23: isValid() {
src/mixins/address.js:41: isValid() {
# and nothing anywhere reports the collision.Two mixins defining the same computed property is silently resolved by registration order, and the component using it has no indication that either exists. That is the specific failure the Options API cannot prevent, because a mixin merges into a namespace it does not own.
Why it happens
A mixin is the only way to share stateful logic between components in the Options API, and it works by merging into this. Everything about the merge is implicit: the source of a property is invisible at the usage site, collisions resolve silently, and a mixin cannot take arguments.
The Composition API replaces the merge with a function call and a return value, which makes all three of those problems disappear at once — not because functions are fashionable but because a function has an explicit input, an explicit output and a name at the call site.
The fix
The same component, without the merge
import { ref, computed } from 'vue'
import { useValidation } from '@/composables/validation'
import { useCurrency } from '@/composables/currency'
export default {
setup(props) {
const form = ref({})
const submitting = ref(false)
const { isValid, errors } = useValidation(form, orderSchema)
const { format } = useCurrency(props.currency)
const canSubmit = computed(() => isValid.value && !submitting.value)
return { form, submitting, isValid, errors, format, canSubmit }
},
}
Where isValid comes from is now visible on the line that uses it, and two composables both returning isValid is a destructuring collision the bundler reports rather than a silent overwrite. That is the entire argument for the change and it is sufficient on its own.
The composable taking form and a schema as arguments is the second thing a mixin could not do. A validation mixin has to agree by convention on which data property holds the form; a composable is told.
ref, reactive, and why .value exists
// reactive: a Proxy over an object. no .value, and it
// cannot be destructured without losing reactivity.
const state = reactive({ count: 0 })
state.count++
const { count } = state // count is now a plain number
// ref: a box with a .value. works for primitives, and
// SURVIVES destructuring, because the box is passed around.
const count = ref(0)
count.value++
// in a template, refs returned from setup() are unwrapped:
// {{ count }} — not count.value
The .value is the tax for JavaScript having no way to observe a reassignment of a primitive binding. A Proxy can intercept property access on an object and nothing can intercept count = 5, so a box is the only mechanism available.
The practical rule that settled: ref for everything, reactive only for an object that is always used as a whole. Mixing them produces code where some things need .value and some do not, and the resulting inconsistency costs more than the extra four characters ever did.
// and the escape hatch when a composable must return a
// reactive object rather than refs
const state = reactive({ count: 0, name: 'x' })
const { count, name } = toRefs(state) // both are refs now
A composable that owns a lifecycle
// composables/pollingResource.js
import { ref, onMounted, onUnmounted } from 'vue'
export function usePollingResource(fetcher, intervalMs = 30000) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
let timer = null
async function refresh() {
loading.value = true
try {
data.value = await fetcher()
error.value = null
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
onMounted(() => {
refresh()
timer = setInterval(refresh, intervalMs)
})
onUnmounted(() => clearInterval(timer))
return { data, error, loading, refresh }
}
The composable registering its own onUnmounted is the part that matters most: cleanup lives beside the thing it cleans up, rather than in a component hook somebody must remember to write. A mixin could do this and could not be used twice in one component without the two intervals fighting over one variable.
The lifecycle hooks work because setup runs with a current-instance context, which means calling a composable outside setup — inside a promise callback, after an await — registers nothing and fails silently. That is the sharpest edge in the whole API and it is worth a lint rule.
$ npm i -D eslint-plugin-vue@next
# .eslintrc: 'vue/no-lifecycle-after-await': 'error'
$ npx eslint src/composables/
src/composables/session.js
18:3 error onMounted is called after await vue/no-lifecycle-after-awaitWhat the rewrite does not have to touch
the Options API is NOT deprecated and is not going away.
setup() sits alongside data/computed/methods in the same
component, and both can be used at once.
so the conversion is per-component and can stop anywhere:
a 40-line presentational component leave it
a component with one mixin leave it
a component with three mixins and convert
logic that appears elsewhere
of 214 components: 31 converted, 183 untouched.This is the part that gets lost in the discussion around the release. The Composition API is additive, and treating the upgrade as a rewrite of every component is a large amount of work for components that had no problem to solve.
The thirty-one that converted were the ones with shared stateful logic, and they were converted over three months rather than in one branch. The remainder are still Options API and are still correct.
The breaking changes that are not about the API at all
// 2: a global Vue, mutated by everything
Vue.use(Router)
Vue.component('AppButton', AppButton)
Vue.prototype. = axios
new Vue({ render: h => h(App) }).$mount('#app')
// 3: an app instance, so two apps on one page do not collide
const app = createApp(App)
app.use(router)
app.component('AppButton', AppButton)
app.config.globalProperties.$http = axios
app.mount('#app')
The global-to-instance change affects every entry point and every plugin, and it is a mechanical edit. It matters on a page mounting two Vue applications — a common arrangement when Vue is embedded in a server-rendered site — where the Vue 2 globals were shared whether that was intended or not.
the rest of the list, in order of how much they hurt:
v-model on a component value/input → modelValue/
update:modelValue. every custom
input component.
filters removed. → methods or computed.
functional components no more { functional: true }.
a plain function taking (props, ctx).
folded into .
IE11 not supported. the build targets
ES2015 and there is no ES5 branch.The v-model rename is the largest of these on an application with a design system, because every wrapped input needs it and the change is invisible until the component is used. A codemod handles the common shape and not the ones with a custom model option.
The IE11 line is the one that decides whether the upgrade happens at all in 2020. Vue 3 has no IE11 support and no plan for one, so an application with a browser requirement stays on Vue 2 regardless of what the Composition API offers.
Verifying it worked
$ npx vitest run
Test Files 47 passed
Tests 412 passed
$ ls src/mixins/
analytics.js # the one that is genuinely cross-cutting
$ wc -l src/components/OrderForm.vue
184 src/components/OrderForm.vue # was 612
$ du -sh dist/
1.1M dist/ # was 1.4MThe composables are testable without mounting a component, which is the outcome that changed how the suite is written — useValidation is a function taking a ref and returning refs, so a test is four lines with no rendering. That was not possible with a mixin at all.
import { ref } from 'vue'
import { useValidation } from '@/composables/validation'
test('rejects a negative quantity', () => {
const form = ref({ quantity: -1 })
const { isValid, errors } = useValidation(form, orderSchema)
expect(isValid.value).toBe(false)
expect(errors.value.quantity).toContain('must be positive')
})
The bundle reduction is mostly tree-shaking rather than the rewrite: Vue 3’s runtime is modular, so an application not using transitions or keep-alive does not ship them. That is a benefit of the upgrade independent of whether any component changed.
What this costs
Two idioms in one codebase, indefinitely. A team where some components use setup and some use data has to know both, and a new person has to learn both — which is a real cost and is smaller than the cost of converting one hundred and eighty-three components that were fine.
The .value is a genuine ergonomic tax and the one thing people bounce off. Forgetting it in a template is harmless because refs are unwrapped there, and forgetting it in a computed produces a value that is a ref object rather than a number, which renders as [object Object] and is confusing exactly once.
The deeper cost is that composables make it easy to build a dependency graph nobody drew. A composable calling three others, each with their own lifecycle hooks, is harder to reason about than the mixin it replaced — the difference is that it is at least traceable. That is an improvement rather than a solution, and the ecosystem spent the following year discovering where the new limits are.