Every Composition API component we had written ended with a return statement listing the things declared immediately above it. Adding a computed property meant adding it twice, and forgetting the second one produced a template rendering nothing with no error.
The symptom
export default {
props: { orderId: { type: Number, required: true } },
emits: ['updated'],
setup(props, { emit }) {
const order = ref(null)
const loading = ref(false)
const total = computed(() => order.value?.totalCents ?? 0)
const canRefund = computed(() => total.value > 0 && !loading.value)
async function refund() { /* ... */ }
// the line that is pure ceremony, and is wrong here:
return { order, loading, total, refund }
// ^ canRefund is missing
},
}
The template referencing canRefund renders it as nothing and logs no warning in production, so the button was permanently disabled on one screen for three weeks. The return statement is a manual re-declaration of everything above it and is exactly the kind of thing a compiler should do.
Why it happens
setup is an ordinary function, so Vue cannot know which of its local bindings the template should see — the return value is the only signal available at runtime. A compiler reading the file can see all of them.
The fix
The same component
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
orderId: { type: Number, required: true },
})
const emit = defineEmits(['updated'])
const order = ref(null)
const loading = ref(false)
const total = computed(() => order.value?.totalCents ?? 0)
const canRefund = computed(() => total.value > 0 && !loading.value)
async function refund() { /* ... */ }
</script>
Every top-level binding is exposed to the template, which removes the return and the class of bug it caused. It also removes the ability to declare something the template must not see — a private helper is now visible, harmlessly and visibly.
The compiled output is marginally more efficient than the explicit form, because the compiler inlines the render function into the setup scope rather than going through a proxy. That is a secondary benefit and is not the reason to adopt it.
The type-only form
<script setup lang="ts">
interface Props {
orderId: number
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
compact: false,
})
const emit = defineEmits<{
(e: 'updated', order: Order): void
(e: 'cancelled'): void
}>()
</script>
The compiler generates the runtime declaration from the type, so props exist once rather than twice and are checked at compile time. The limitation in 3.2 is that the type must be resolvable in the same file — an interface imported from another module cannot be used, which is the constraint that sends people back to the runtime form.
withDefaults exists because a type has no defaults and it reads awkwardly enough that most people meet it via an error message. The emit type signature is the underrated half: a typo in an event name is now a compile error rather than an event nobody receives.
What becomes harder
no longer possible in a script setup block:
a render function instead of a template
two component definitions in one file
a `name` option — the compiler infers it from the
filename, which breaks <keep-alive include="...">
and recursive self-reference in some tooling
inheritAttrs: false → needs defineOptions, which is
not in 3.2 yet
the escape hatch: a SECOND, normal <script> block in
the same file, alongside the setup one.<script>
export default { name: 'OrderPanel', inheritAttrs: false }
</script>
<script setup>
const order = ref(null)
</script>
// both blocks are merged by the compiler. this is the
// supported arrangement, not a workaround.
The two-block form is documented and is the answer for the handful of options with no macro yet. It looks like a workaround and reads badly, and it is temporary — defineOptions arrives later and removes the need.
The migration, which is per component
214 components. what got converted:
47 already using setup(), with a return statement
→ mechanical, an hour each in batches
0 Options API components with no shared logic
→ left alone. there is no benefit.
6 components with a render function
→ left alone, deliberately
and the rule that made it a non-event: convert when you
are already changing the file, never as its own change.Converting only files already being changed for another reason is what kept this from being a three-week branch that conflicts with everything. Six months later the forty-seven were done and no release had contained a conversion-only commit.
Verifying it worked
$ npx vitest run
Test Files 47 passed
Tests 412 passed
$ grep -rn 'return {' src/components/*.vue | wc -l
0
$ npx vue-tsc --noEmit
# no output
$ npx vite build
✓ 412 modules transformed. built in 8.9s
dist/assets/index.d4e2b19f.js 84.10 KiB / gzip: 26.90 KiB
# was 88.40 / 28.14The type check is the assertion that the type-only props are doing something, and it is the piece that catches the class of bug the original return statement caused — a template referencing something that does not exist is now a compile error rather than a blank space.
The bundle reduction is four kilobytes and is not the reason to do this. The reason is the three weeks with a permanently disabled button that nobody could explain.
What this costs
A syntax the tooling has to understand, and in August 2021 not all of it does — an editor without the right Vue extension shows the setup block as unparsed, and two of our lint rules had to be updated. That resolves over the following year and it is a real cost at the moment of adoption.
Everything at the top level is now visible to the template, which removes a distinction that occasionally mattered and mostly did not. A helper function that should be private is exposed, which is harmless and is a small loss of expressiveness — and the alternative was a return statement that got it wrong.