Managing Props, Emits, and State with Vue 3 <script setup>
Master Vue 3's <script setup> syntax to eliminate boilerplate. Learn how to implement props, emits, and reactive state while avoiding common reactivity pitfalls.
22 Dec 2025, 17:00 UTC

The Core Problem: Boilerplate in Composition API
In the standard Composition API, developers must manually return every variable, function, and computed property from the setup() function to make them available to the template. This creates redundant code and increases the risk of forgetting to expose a necessary binding.
The <script setup> syntax solves this by acting as compile-time sugar. Any top-level binding—including imports, variables, and functions—is automatically exposed to the template, allowing you to focus on logic rather than plumbing.
Implementing a Reactive Component
To use <script setup>, replace the standard <script> tag in your Single File Component (SFC). The following example demonstrates a counter component that handles external props, emits events to a parent, and manages internal reactive state.
<template>
<div class="counter-card">
<h3>{{ title }}</h3>
<p>Current Value: {{ count }}</p>
<p>Double Value: {{ doubled }}</p>
<button @click="increment">Add 1</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// 1. Props: defineProps is a compiler macro (no import needed)
const props = defineProps({
title: { type: String, required: true }
});
// 2. Emits: defineEmits declares events the component can trigger
const emit = defineEmits(['value-changed']);
// 3. Reactive State: ref() for primitive values
const count = ref(0);
// 4. Derived State: computed() for cached calculations
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
// Trigger the event for the parent component
emit('value-changed', count.value);
}
</script>
Mechanism Breakdown
- defineProps: This macro defines the component's expected inputs. It returns a reactive object containing the props. Because it is a macro, it is processed during compilation and does not need to be imported from 'vue'.
- defineEmits: This creates an
emitfunction used to notify parent components of events. Declaring emits explicitly prevents them from being treated as fallthrough attributes. - ref() vs reactive():
refis used here for a number (primitive). In the<script>block, you must access the value via.value. In the<template>, Vue automatically unwraps it, so{{ count }}is sufficient.
Limitations and Version-Specific Behavior
Reactivity Loss during Destructuring
A common mistake is destructuring the object returned by defineProps. In Vue versions prior to 3.5, doing const { title } = defineProps(...) breaks reactivity; if the parent updates the prop, the local title variable will not update.
Verification: To check your version, run npm list vue. If you are on a version lower than 3.5, always access props via the props object (e.g., props.title) or use toRefs().
The .value Pitfall
Because ref requires .value in JavaScript but not in templates, developers often make two mistakes:
- Missing .value: Writing
count++instead ofcount.value++in a function. This modifies the ref object itself rather than the value, and the UI will not update. - Redundant .value: Writing
{{ count.value }}in the template. While this often works, it is non-idiomatic and can lead to errors if the variable is not a ref.
Component Isolation
Components using <script setup> are closed by default. This means a parent component cannot access internal functions or state via a template ref. To expose specific properties, you must use the defineExpose macro:
<script setup>
function internalReset() { count.value = 0; }
// Explicitly allow parent access to this function
defineExpose({ reset: internalReset });
</script>
Practical Verification
To verify your implementation is working correctly:
- State Check: Ensure that clicking the increment button updates the
doubledvalue instantly. If it doesn't, check if you usedrefand accessed.valuein the logic. - Event Check: In the parent component, add
@value-changed="console.log"to the child tag. Verify the console logs the new number on every click. - Prop Check: Change the
titleprop in the parent dynamically. If the child's heading doesn't update, you have likely destructured the props and lost reactivity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.