This guide bridges the gap between backend logic and frontend reactivity, focusing on Vue 3, Composition API, and TypeScript.
1. Mental Model Shift #
Reactivity vs. Backend State #
-
Backend: Request
$\to$ Process$\to$ Response. State is usually transient (per request) or persisted (Database/Redis). Logic is linear. - Vue Frontend: The app is a long-lived process. "State" is held in memory (JavaScript Heap).
-
The Shift: Vue uses a Reactivity System based on JavaScript
Proxyobjects. When you mutate a variable, Vue intercepts the set operation and automatically triggers a re-render of the DOM nodes dependent on that variable. You do not manually callrender().
Component Thinking & Service Architecture #
- Backend Class/Service: Encapsulates logic and data access.
- Vue Component: Encapsulates Logic + Data + View (HTML/CSS).
- Analogy: Treat a component like a self-contained Class instance.
- Props: Constructor arguments (Inputs).
- Emits: Events/Observer pattern (Outputs).
- Slots: Dependency Injection for UI fragments.
Templates as Compiled Render Functions #
Vue templates look like HTML, but they are compiled into JavaScript render functions.
- Concept: Just as an ORM translates object manipulation to SQL, Vue translates templates to Virtual DOM nodes.
- Implication: You can write full JavaScript expressions inside bindings. It is statically analyzed for performance (hoisting static nodes).
2. Syntax & Core Concepts #
We use <script setup lang="ts">, which is syntactic sugar for the Composition API. It compiles the contents directly into the component's setup() function.
Template Syntax #
<script setup lang="ts">
const dynamicId = "user-123";
const isDisabled = true;
const rawHtml = "<strong>Warning</strong>";
</script>
<template>
<!-- Text Interpolation -->
<span>{{ dynamicId }}</span>
<!-- Attribute Binding (v-bind shorthand ':') -->
<div :id="dynamicId"></div>
<!-- Boolean Attributes (removes attribute if false) -->
<button :disabled="isDisabled">Submit</button>
<!-- Raw HTML (Use sparingly! XSS risk) -->
<span v-html="rawHtml"></span>
<!-- Event Binding (v-on shorthand '@') -->
<button @click="isDisabled = !isDisabled">Toggle</button>
</template>Reactivity: ref vs reactive vs computed #
This is the bread and butter of Vue state.
Used for primitives (string, number, boolean) OR objects. It wraps the value in an object with a .value property.
- Backend Analogy: A pointer or wrapper class.
Used strictly for objects. It makes the object itself a Proxy.
- Gotcha: You cannot destructure a
reactiveobject without losing reactivity (unless you usetoRefs).
Derived state. It caches the result and only re-evaluates if dependencies change.
- Backend Analogy: A database View or a memoized getter.
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
// 1. REF (Recommended default for most cases)
const count = ref<number>(0); // Generics available
// Mutation in JS requires .value
const increment = () => count.value++;
// 2. REACTIVE (Good for grouped form state)
interface User {
name: string;
role: 'admin' | 'guest';
}
const user = reactive<User>({
name: 'Alice',
role: 'guest'
});
// Mutation does NOT need .value
user.role = 'admin';
// 3. COMPUTED (Read-only by default)
const displayTitle = computed<string>(() => {
// Auto-tracks 'user.name' and 'count.value'
return `${user.name} has clicked ${count.value} times`;
});
</script>
<template>
<!-- In templates, refs are auto-unwrapped (no .value needed) -->
<h1>{{ displayTitle }}</h1>
</template>Component Lifecycle Hooks #
Mapping standard backend concepts to Vue Lifecycle.
<script setup lang="ts">
import { onMounted, onUnmounted, onUpdated } from 'vue';
// CONSTRUCTOR equivalent:
// Code at the top level of <script setup> runs when component instance is created.
const initTime = Date.now();
// DB CONNECTION OPEN / INIT equivalent:
// DOM is ready. Good place for API calls.
onMounted(async () => {
console.log('Component rendered to DOM');
// await fetchData();
});
// STATE CHANGE equivalent:
// Reactive state changed and DOM re-rendered.
onUpdated(() => {
console.log('View updated');
});
// DESTRUCTOR / CLEANUP equivalent:
// Close websocket connections, clear intervals.
onUnmounted(() => {
console.log('Component destroyed');
});
</script>Template Directives #
Logic inside your HTML.
<script setup lang="ts">
import { ref } from 'vue';
interface Item { id: number; label: string };
const isLoggedIn = ref(true);
const items = ref<Item[]>([
{ id: 1, label: 'Backend' },
{ id: 2, label: 'Frontend' }
]);
const inputValue = ref('');
</script>
<template>
<!-- v-if: Real conditional rendering (blocks removed from DOM) -->
<div v-if="isLoggedIn">Welcome User</div>
<div v-else>Please Log In</div>
<!-- v-show: Toggles CSS display:none (better for frequent toggles) -->
<div v-show="isLoggedIn">Secret Menu</div>
<!-- v-for: Loop. ALWAYS use :key (like a DB Primary Key for DOM diffing) -->
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index }} - {{ item.label }}
</li>
</ul>
<!-- v-model: Two-way data binding for form inputs -->
<!-- Modifiers: .trim (strip whitespace), .number (typecast) -->
<input v-model.trim="inputValue" placeholder="Type here..." />
<p>Live Output: {{ inputValue }}</p>
</template>Slots & Scoped Slots #
Slots are Template Injection. They allow a parent component to inject HTML into a child component.
- Default/Named Slots: Like a "Strategy Pattern" where the implementation of a specific UI section is passed in.
- Scoped Slots: The child passes data back to the parent's injected template. (Like a callback function/lambda).
The Child (Card.vue):
<template>
<div class="card">
<div class="header">
<!-- Named Slot -->
<slot name="header">Default Header</slot>
</div>
<div class="content">
<!-- Default Slot with Scoped Data exposed to parent -->
<slot :ctx="internalData"></slot>
</div>
</div>
</template>
<script setup lang="ts">
const internalData = { id: 99, status: 'active' };
</script>The Parent (Usage):
<Card>
<!-- Injecting into 'header' slot -->
<template #header>
<h1>Custom Title</h1>
</template>
<!-- Default slot receiving props from child (destructured) -->
<template #default="{ ctx }">
<p>Status is: {{ ctx.status }}</p>
</template>
</Card>Composables #
This is the equivalent of Service Classes or Utility Functions in backend. It extracts stateful logic from components.
Naming convention: useCamelCase.
definition: useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
// Return type inference is automatic, but explicit interfaces are better
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
// Expose state and methods
return { x, y }
}Usage: Component.vue
<script setup lang="ts">
import { useMouse } from './useMouse'
// Logic acts just like local variables
const { x, y } = useMouse()
</script>
<template>
Mouse position is at: {{ x }}, {{ y }}
</template>3. Modern Patterns & Best Practices #
Component Communication (TypeScript Interfaces) #
In Vue 3 <script setup>, we use generics to define strict contracts for inputs (props) and outputs (emits). This is effectively your Interface Definition.
<script setup lang="ts">
// 1. Define Interfaces (DTOs)
interface UserProps {
id: string;
name: string;
age?: number; // Optional
}
// 2. Define Props (Inputs)
// 'withDefaults' compiles to default values if prop is missing
const props = withDefaults(defineProps<UserProps>(), {
age: 18
});
// 3. Define Emits (Outputs/Events)
// Strict typing for event payloads
const emit = defineEmits<{
(e: 'update', value: string): void;
(e: 'delete', id: string): void;
}>();
// Usage
const handleDelete = () => {
emit('delete', props.id);
};
</script>Dependency Injection (Provide/Inject) #
To avoid "Prop Drilling" (passing data through 5 layers of components), Vue uses a DI system similar to backend Service Locators.
Type-Safe Injection Keys:
Create a symbols file (e.g., keys.ts) so you don't rely on magic strings.
// keys.ts
import type { InjectionKey, Ref } from 'vue';
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('Theme');Provider (Parent/Root):
import { provide, ref } from 'vue';
import { ThemeKey } from './keys';
const theme = ref('light');
provide(ThemeKey, theme); // Type checked automaticallyInjector (Deeply Nested Child):
import { inject } from 'vue';
import { ThemeKey } from './keys';
// Second argument is the default value if provider not found
const theme = inject(ThemeKey, ref('light')); State Management: Pinia #
Vuex is deprecated. Pinia is the standard. It creates a global singleton store.
- Mental Model: Think of a Pinia store as a "Global Composable" or a "Singleton Service".
- Setup Stores: We prefer the syntax that looks exactly like standard Vue components.
// stores/userStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
// State (equivalent to private properties)
const user = ref({ name: 'Alice', role: 'admin' });
const token = ref<string | null>(null);
// Getters (equivalent to computed properties)
const isAuthenticated = computed(() => !!token.value);
// Actions (equivalent to public methods)
function logout() {
token.value = null;
user.value.name = '';
}
return { user, token, isAuthenticated, logout };
});Usage in Component:
import { useUserStore } from '@/stores/userStore';
// invoked as a hook
const userStore = useUserStore();
// Direct access (reactive)
console.log(userStore.user.name);
userStore.logout();Async State & API (TanStack Query) #
While you can use axios inside onMounted, it causes "waterfall" loading and boilerplate for loading/error states. Use TanStack Query (Vue Query).
- Concept: Server state is not client state. It is a cache.
<script setup lang="ts">
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
import axios from 'axios';
// Fetcher function
const fetchTodos = async () => (await axios.get('/api/todos')).data;
// 1. Queries (GET)
// automatically handles isLoading, isError, data, caching, refetching
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// 2. Mutations (POST/PUT/DELETE)
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: (newTodo) => axios.post('/api/todos', newTodo),
onSuccess: () => {
// Invalidate cache to trigger refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
</script>
<template>
<span v-if="isLoading">Loading...</span>
<ul v-else>
<li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
</ul>
</template>Performance: Watchers & Memoization #
watch(source, callback): Lazy. Only runs when specific source changes. Good for API calls on input change.watchEffect(callback): Eager. Runs immediately, tracks everything used inside it. Good for quick DOM manipulation or logging.
import { watch, watchEffect, ref } from 'vue';
const search = ref('');
const page = ref(1);
// 1. Specific Watcher (Precise control)
watch(search, (newValue, oldValue) => {
console.log(`Search changed from ${oldValue} to ${newValue}`);
// Debounce logic usually goes here
});
// 2. Array Watcher (Multiple sources)
watch([search, page], () => {
fetchResults(search.value, page.value);
});
// 3. Cleanup Side Effects
watch(search, async (val, oldVal, onCleanup) => {
const controller = new AbortController();
onCleanup(() => {
// Abort previous fetch if user types again quickly
controller.abort();
});
await fetch(`/api?q=${val}`, { signal: controller.signal });
});4. Ecosystem Integration #
Router (Vue Router 4) #
Maps URLs to Components.
- Lazy Loading: Crucial for bundle size. Splits code into chunks.
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/Home.vue') }, // Lazy load
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true } // Custom metadata
}
]
});
// Navigation Guards (Middleware)
router.beforeEach((to, from, next) => {
const isAuthenticated = checkToken(); // pseudo-function
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login'); // Redirect
} else {
next(); // Proceed
}
});Form Handling & Validation #
Don't write manual validation logic. Use Zod (schema validation) + VeeValidate.
- Backend approach: Validate Request DTO.
- Frontend approach: Validate Form State against Schema.
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod';
// 1. Define Schema (Identical to backend validation logic)
const schema = toTypedSchema(z.object({
email: z.string().email({ message: 'Invalid email' }),
password: z.string().min(6)
}));
// 2. Initialize Form
const { errors, defineField, handleSubmit } = useForm({ validationSchema: schema });
// 3. Bind fields (returns bindings and props)
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const onSubmit = handleSubmit(values => {
// 'values' is typed correctly here
console.log(values.email, values.password);
});
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" v-bind="emailAttrs" />
<span>{{ errors.email }}</span>
<button>Submit</button>
</form>
</template>Testing Strategies #
Modern Vue testing uses Vitest (Jest replacement, faster) and Vue Test Utils or Testing Library.
- Pattern: Test behavior, not implementation details.
// Counter.test.ts
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.vue';
describe('Counter', () => {
it('increments count when button is clicked', async () => {
const wrapper = mount(Counter);
// Select element
const button = wrapper.find('button');
// Trigger Event
await button.trigger('click');
// Assert Rendered Output
expect(wrapper.text()).toContain('Count: 1');
});
});