Shedding the Boilerplate: Modern Vue 3 Patterns You Should Be Using

Shedding the Boilerplate: Modern Vue 3 Patterns You Should Be Using

The Vue 3 Composition API drastically improved how we architect frontend logic. However, the early days of <script setup> still carried a frustrating amount of boilerplate—especially when it came to two-way binding, maintaining reactivity on props, and handling template references.

Over the course of the 3.4 and 3.5 releases, the Vue core team systematically eliminated these friction points through powerful compiler macros.

If you are still writing Vue 3 the way you learned it in 2022, you are carrying unnecessary technical weight. Here is a deep dive into the modern syntax that separates junior codebases from senior architectures.

1. The Death of update:modelValue: Enter defineModel

For years, creating a custom input component in Vue 3 felt unnecessarily verbose. To support v-model, you had to define a prop, define an emit, and manually wire them together in your template.

The Old Way (Pre-3.4):

<script setup>
const props = defineProps({
  modelValue: String,
});
const emit = defineEmits(["update:modelValue"]);

const updateValue = (event) => {
  emit("update:modelValue", event.target.value);
};
</script>

<template>
  <input :value="props.modelValue" @input="updateValue" />
</template>

The Modern Way: Vue 3.4 stabilized the defineModel() macro. It automatically registers the prop and the event listener under the hood, returning a standard ref that you can directly mutate.

<script setup>
const model = defineModel({ type: String, default: "" });
</script>

<template>
  <input v-model="model" />
</template>

When you mutate model.value (or bind it directly via v-model), Vue automatically emits the update:modelValue event to the parent. You can even declare multiple models in a single component by passing a string name: const title = defineModel('title').

2. Reactive Props Destructuring

One of the fundamental rules of JavaScript is that destructuring an object breaks reactivity. In early Vue 3, if you destructured your props, they became static values. You had to wrap them in toRefs() to keep them reactive, which cluttered the setup script.

The Old Way (The toRefs nightmare):

<script setup>
import { toRefs } from "vue";

const props = defineProps({
  count: Number,
  title: String,
});

// We had to do this to use 'count' directly in watchers/computeds
const { count, title } = toRefs(props);
</script>

The Modern Way: With Reactive Props Destructure (stabilized in Vue 3.5), the Vue compiler is now smart enough to intercept destructured props and maintain their reactivity automatically. It also gives us a native, vanilla JavaScript way to set default values.

<script setup>
import { watch } from 'vue'

// Destructure freely, assign defaults naturally
const { count = 0, title = 'Default Title' } = defineProps<{
  count?: number
  title?: string
}>()

// 'count' remains perfectly reactive!
watch(() => count, (newVal) => {
  console.log('Count changed:', newVal)
})
</script>

Under the hood, the compiler transforms references to the destructured count variable back into props.count. It’s pure syntactic sugar that keeps your code incredibly clean.

3. Type-Safe Refs with useTemplateRef

Template refs used to rely on a confusing implicit matching system. You had to declare a ref with the exact same variable name as the ref="myEl" string in your template. If you made a typo, it silently failed.

The Old Way:

<script setup>
import { ref, onMounted } from "vue";

// This name must perfectly match the template string
const inputElement = ref(null);

onMounted(() => {
  inputElement.value?.focus();
});
</script>

<template>
  <input ref="inputElement" />
</template>

The Modern Way: Vue 3.5 introduced useTemplateRef(). It explicitly decouples the variable name from the template string, making the connection obvious and improving TypeScript support for dynamic refs.

<script setup>
import { useTemplateRef, onMounted } from "vue";

// Explicitly bind to the 'my-input' template ref
const inputEl = useTemplateRef("my-input");

onMounted(() => {
  inputEl.value?.focus();
});
</script>

<template>
  <input ref="my-input" />
</template>

Conclusion

The Vue core team has done an incredible job of listening to developer feedback and actively reducing friction. Adopting defineModel, reactive destructuring, and useTemplateRef won't just make your code shorter—it will make it more readable, less prone to typos, and much easier to maintain.

Are you struggling to scale a messy Vue codebase? I specialize in auditing frontend architectures and implementing clean, modern standards. Check out my Custom Web App Development services to see how we can build your next project the right way.

A Note on Process

This blog serves as a personal learning journal where I dive into topics that capture my curiosity. To refine my understanding and the clarity of the writing, I use AI as a collaborative partner in the research and composition process.

Built with the help of AI

By Ismail Ammor ·