Vue 2.7 exists so you do not have to migrate yet

Vue 3 shipped in September 2020 and this application was still on 2.6 in July 2022, because a hundred and eighty of its two hundred and fourteen components worked and the ecosystem had taken eighteen months to catch up. Vue 2.7 removes the main reason to hurry by backporting the Composition API to the 2.x line.

The symptom

$ npx vue-migration-helper .
  214 components scanned. breaking changes: 88
    filters (removed in 3)  41   v-model on components  22
    functional components   11   $listeners              8
    $children                6

$ npm ls | grep -c 'vue-'
14        # of which 4 have no Vue 3 version

# 88 breaking changes and 4 blocked dependencies is not a
# fortnight. it is a quarter, competing with everything.

Eighty-eight breaking changes across two hundred and fourteen components is a real migration, and the four dependencies with no Vue 3 release are a constraint nobody on this team controls. The application worked; the pressure to migrate came from the composables that could not be written, not from anything broken.

Why it happens

A framework major with a genuinely better authoring model creates pressure to migrate that is unrelated to anything failing, and the pressure lands on a codebase that is fine. 2.7 relieves it by making the better model available without the migration.

The fix

What 2.7 gives you

// works identically in 2.7 and 3.x
<script setup>
import { ref, computed, watch } from 'vue'
import { useOrderTotals } from '@/composables/orderTotals'

const props = defineProps({ orderId: { type: Number, required: true } })
const emit = defineEmits(['updated'])

const { total, vat, refresh } = useOrderTotals(props.orderId)
const formatted = computed(() => format(total.value))
</script>

// the composables directory is now portable between the
// two versions, which is the actual win.

The composables being version-portable is what makes this more than a syntax convenience: the shared logic — the part worth extracting — can be written once and will survive the eventual migration untouched. That turns the migration from a rewrite into a component-by-component port.

What it does not give you

  Teleport                 no. a modal still fights its
                           stacking context.
  Fragments / multi-root   no. every template needs a
                           single root element.
  the reactivity rewrite   NO. 2.7 is still
                           Object.defineProperty.
  the smaller runtime      no. 2.7 is slightly LARGER
                           than 2.6.
  better tree-shaking      no.

the third is the one that matters, and it is invisible.
// 2.7: still the 2.x reactivity engine
const state = reactive({ items: {} })

state.items.newKey = 'value'    // NOT reactive in 2.7
Vue.set(state.items, 'newKey', 'value')   // required

state.list[0] = 'x'             // NOT reactive in 2.7
state.list.splice(0, 1, 'x')    // required

// both of these work correctly in Vue 3, because Proxy
// intercepts them. code written against 2.7 can contain
// assumptions that only hold on 2.x.

This is the trap in the whole arrangement: code that compiles on both versions can still be written with the 2.x caveats in mind, and the caveats are invisible on 3 because Vue.set is a no-op there. A component that works on both is not proof that it is correct on both.

The mitigation is a lint rule forbidding Vue.set and index assignment in anything new, which forces the code to be written as if the Proxy engine were present. Existing components keep their calls and are ported when touched.

Writing components that compile on both

portable, and therefore the subset for new code:
  script setup, defineProps, defineEmits
  ref, reactive, computed, watch, watchEffect
  the lifecycle hooks, provide/inject, composables

to avoid in new code:
  Vue.set / this.$set              2.x only
  filters                          2.x only
  Teleport, Suspense               3.x only
  multiple root elements           3.x only
  emits as an object with validators   3.x only

A written-down subset is what makes “write it so it ports later” an instruction rather than an aspiration, and it belongs in the contributing guide rather than in somebody’s memory. Enforcing the 2.x-only half with lint rules is straightforward; the 3.x-only half is enforced by it not compiling.

The end-of-life date, which is the actual constraint

Vue 2 end of life: 31 December 2023. no security patches
after it, no compatibility fixes, and the ecosystem moves
regardless — a library drops Vue 2 on its own schedule.

so 2.7 buys eighteen months and the migration happens
inside them. the plan, written down:

  2022 H2  every new component in the portable subset
  2023 Q1  the 4 blocked dependencies resolved or replaced
  2023 Q2  filters and v-model, mechanically
  2023 Q3  the migration build, then Vue 3
  2023 Q4  contingency

A deferral with a date attached is a plan; without one it is a decision to migrate under pressure in December 2023. Writing the quarters down at the moment of deferring is what makes it a deliberate choice, and the contingency quarter is the part that acknowledges the estimate is wrong.

The compat build, which is the other option

// @vue/compat: Vue 3 running with 2.x behaviour, warning
// on every incompatibility
resolve: {
  alias: { vue: '@vue/compat' },
},

// and per-feature opt-out as they are fixed
configureCompat({
  FILTERS: false,               // fixed: 41 of 41
  COMPONENT_V_MODEL: 'suppress-warning',
  RENDER_FUNCTION: true,        // not started
})

// which is a genuinely good migration tool and requires
// the 4 blocked dependencies to support Vue 3 first.

The compat build is the better migration path when the dependencies allow it, because it is incremental within a single running application rather than incremental across releases. It was not available here for exactly one reason — four packages — which is the recurring shape of every framework upgrade in this archive.

Verifying it worked

$ npm i [email protected]
$ npx vitest run
 Test Files  47 passed
      Tests  412 passed

$ npx vite build
✓ built in 9.02s
dist/assets/vendor.js  188.4 KiB  # was 184.1 — 2.7 is larger

$ npx eslint src/ --rule 'no-restricted-properties: ...'
# 0 uses of Vue.set in code written since July

# and the portability check, which is the real assertion:
#   every composable in src/composables/ imported into a
#   Vue 3 scratch project and unit tested there. 14/14 pass.

Testing the composables against an actual Vue 3 project is the assertion that the portable subset is portable, and it is worth the scratch project — a claim about future compatibility that is never exercised is a claim that will be wrong. Fourteen of fourteen passing is what makes the 2023 plan credible.

What this costs

A deliberate delay with a deadline, which is a plan and is also eighteen months of running on a version that is going out of support. The bundle is slightly larger, the reactivity caveats remain, and every new component is written against a subset that somebody has to remember.

The larger risk is that the deadline slips because nothing forces it. Vue 2 reaching end of life produces no failure on the first of January 2024 — it produces a gradually worsening position that nobody has to act on, which is exactly the shape of deferral that becomes permanent. The quarterly plan is the only defence and it is a piece of paper.