Spring Sale 🍃 is live! Get 20% off ANY package with the coupon SPRINGBREAK

Vue 3.3: Improved DX

Vue 3.3 has just been released, and it introduces multiple quality-of-life developer experience features and improvements.

The Vue.js team has resolved many long-standing pain points when using Vue with TypeScript, making it even easier to build completely typed codebases.

This post highlights the most important features introduced with the recent release. For more info, check out the official press release or the full changelog on GitHub.

Dependency Updates

When upgrading to 3.3, it is recommended to also update the following dependencies:

  • volar / vue-tsc@^1.6.4
  • vite@^4.3.5
  • @vitejs/plugin-vue@^4.2.0
  • vue-loader@^17.1.0 (if using webpack or vue-cli)

New defineModel macro

Previously, to enable two-way model binding within a component, it was needed to declare a prop and emit a corresponding update:propName event on the value change.

<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
 
const emit = defineEmits<{
(e: "update:modelValue", value: string): void
}>()
 
const value = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
})
</script>
 
<template>
<input v-model="value" />
</template>

The recent Vue.js update introduces a new macro defineModel simplifying the whole process.

Starting 3.3, you no longer need to define a prop and an event explicitly. When compiled, the macro will declare a prop with the same name and a corresponding update:propName event.

<script setup lang="ts">
const value = defineModel<string>()
</script>
 
<template>
<input v-model="value" />
</template>

This feature is experimental and requires explicit opt-in. To enable the feature using Vite, you need to apply the following changes to your vite.config.js:

export default {
plugins: [
vue({
script: {
defineModel: true
}
})
]
}

Typed defineEmits macro improvements

Previously, the type parameter for defineEmits only supported the call signature syntax:

const emit = defineEmits<{
(e: 'search', query: string): void
}>()

Vue 3.3 introduced a more ergonomic way to declare emits with types.

const emit = defineEmits<{
search: [query: string]
}>()

In the type literal, the key is the event name, and the value is an array type specifying the payload.

The call signature syntax remains to be supported.

New defineOptions macro

Before Vue 3.3, you needed to use an additional separate <script> block to define component options when declaring components using <script setup> syntax.

// BEFORE
 
<script setup>
// the component's logic goes here
</script>
 
<script>
export default {
inheritAttrs: false
}
</script>

Starting this version, you can define component’s options using the new defineOptions macro right within the primary <script setup> block.

// AFTER
 
<script setup>
defineOptions({ inheritAttrs: false })
</script>

This feature can also be handy for defining persistent layouts in Inertia.js apps.

<script setup>
import Layout from './Layout'
 
defineOptions({ layout: Layout })
</script>

Imported types in macros

Previously, it was impossible to use externally imported types in defineProps and defineEmits macros, as they were limited to local type literals and interfaces.

This limitation is now resolved in 3.3. The compiler can now resolve imported types, and supports a limited set of complex types:

<script setup lang="ts">
import type { Props } from "./foo"
 
defineProps<Props>()
</script>

In addition, more complex types are also supported, e.g., you can do:

import { Props } from "./foo"
 
defineProps<Props & {
additionalProp?: string
}>()

Do note that complex types support is AST-based (not using TS itself), and therefore, not 100% comprehensive. For example, you cannot use conditional types for the props type itself:

import { Props } from "./foo"
 
// will throw a compiler error
defineProps<Props extends object ? Props : {}>()

This improvement lets you declare complete view-model interfaces, utilizing the concepts described in Advanced Inertia.

<script setup lang="ts">
import DashboardPage from "@/types/pages.ts"
 
const props = defineProps<DashboardPage>
</script>

Reactive props destructure

The update also introduces a compile-time transform that makes destructured bindings from defineProps reactive.

This means that you can now access props directly without declaring a parent object.

<script setup lang="ts">
import { watch } from "vue"
 
const { query } = defineProps<{
query: string
}>()
 
watch(query, () => {
// referencing a destructured binding in tracking contexts
// registers it as a dependency.
// this will log every time the count prop changes from parent
})
</script>
 
<template>{{ query }}</template>

Users can leverage the native destructure default value syntax to declare default values for props:

const { query: 'default value' } = defineProps<{
query: string
}>()

This is implemented and shipped as an experimental feature and requires explicit opt-in.

// vite.config.js
 
export default {
plugins: [
vue({
script: {
propsDestructure: true
}
})
]
}

Generic components

Components using <script setup> can now accept generic type parameters via the generic attribute. This allows inferring generics when passing props on the <template>, JSX or h.

<script setup lang="ts" generic="T">
defineProps<{
list: T[],
modelValue?: T
}>
 
defineEmits<{
(e: `update:modelValue`, payload: T): void
}>()
</script>

The feature lets you create versatile components accepting payloads of different types, such as custom select components.

Use Inertia.js like a boss

Learn advanced concepts and make apps with Laravel and Inertia.js a breeze to build and maintain.

© 2023 Boris Lepikhin. All rights reserved.