Skip to content

Instantly share code, notes, and snippets.

@suntong
Created December 2, 2025 08:05
Show Gist options
  • Select an option

  • Save suntong/59d8e67e6ae8bae891fe41198b11e916 to your computer and use it in GitHub Desktop.

Select an option

Save suntong/59d8e67e6ae8bae891fe41198b11e916 to your computer and use it in GitHub Desktop.

Modern Vue 3 Reference for Backend Developers

This guide bridges the gap between backend logic and frontend reactivity, focusing on Vue 3, Composition API, and TypeScript.

Table of Contents

  1. Mental Model Shift

  2. Syntax & Core Concepts

  3. Modern Patterns & Best Practices

  4. Ecosystem Integration


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 Proxy objects. 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 call render().

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.

ref()

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.

reactive()

Used strictly for objects. It makes the object itself a Proxy.

  • Gotcha: You cannot destructure a reactive object without losing reactivity (unless you use toRefs).

computed()

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 automatically

Injector (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');
  });
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment