Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save suntong/6c160d32d51b03f94cbe8d1905507f5a to your computer and use it in GitHub Desktop.

Select an option

Save suntong/6c160d32d51b03f94cbe8d1905507f5a to your computer and use it in GitHub Desktop.

Vue 3 Master Reference for Backend Developers (2025 Edition) #

Table of Contents


1. MENTAL MODEL SHIFT #

1.1 Vue’s reactivity vs backend state management #

Backend mental model

  • State is usually:
    • Short‑lived (per request/job), or
    • Long‑lived but centralized (DB, cache, in‑process singletons/actors).
  • You pull state, compute, return a response.
  • No automatic “UI update” – HTML is rendered once per response.

Vue mental model

  • Your component tree is always “alive” in the browser.
  • Vue tracks which DOM fragments depend on which reactive values.
  • When reactive state changes, Vue:
    • Schedules an update in the next tick.
    • Re-runs the render function for affected components.
    • Patches only the minimal DOM that changed.

Reactivity mechanics (simplified)

  • Vue wraps your state in reactive containers:
    • ref() for single values.
    • reactive() for objects/arrays.
    • computed() for derived values.
  • When a render function or computed() reads reactive state, Vue records that dependency.
  • When that state changes, Vue:
    • Marks dependents as “dirty”.
    • Re-runs them in a batched, efficient way.

This is closer to:

  • DB change → invalidate cache entries → recompute only dependent cached values

than to:

  • “Render HTML template once per request”.

Key shift: you don’t imperatively re-render. You declare:

  • what the state is, and
  • how the UI depends on it,

and Vue handles propagation.


1.2 Component thinking vs backend service architecture #

Backend services

  • You design modules/microservices around domains:
    • UserService, OrderService, AuthService.
  • They expose methods; they don’t render UI.
  • Typically stateless per request.

Vue components

  • Each component is:
    • A small UI unit (markup + styling + behavior).
    • With inputs (props), outputs (emits), and internal state.
  • Form a tree:
    • AppLayoutPageWidgetButton
  • Data flow:
    • Down: props (like function parameters / DTOs).
    • Up: events (emit) (like callbacks or messages on a bus).

Where do “services” live?

  • Logic that is not inherently visual (API calls, business rules) shouldn’t stay inside components.
  • Factor that logic into composables (see §2.6):
    • useAuth(), useUser(), useCart().
  • Composables ≈ application/domain services in backend terms.

So:

  • Components = small, nested controller+view units.
  • Composables = services.
  • Stores (Pinia) = in-memory domain aggregates / singletons.

1.3 Template syntax as compiled render functions #

Templates are not HTML with magic attributes. They’re a DSL that compiles to render functions.

Example:

<template>
  <button @click="count++">{{ count }}</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

Roughly compiles to:

import { createElementVNode, toDisplayString, openBlock, createElementBlock } from 'vue'

export function render(_ctx: any) {
  return (openBlock(), createElementBlock(
    "button",
    { onClick: () => _ctx.count++ },
    toDisplayString(_ctx.count)
  ))
}

Implications:

  • Templates are declarative, but under the hood they’re just JS functions.
  • Reading reactive values in templates automatically registers dependencies.
  • The compiler:
    • Separates static vs dynamic parts.
    • Minimizes DOM work similarly to a carefully hand-written view layer.

2. SYNTAX & CORE CONCEPTS #

2.1 Composition API vs Options API #

Options API (older style)

import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 0
    }
  },
  computed: {
    double() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    console.log('Mounted')
  }
})
  • Logic is grouped by option type (data, methods, computed, watch, etc.).
  • Easy onboarding; harder to maintain for large features (related logic is spread across options).

Composition API (modern, recommended)

With <script setup>:

<template>
  <button @click="increment">
    {{ count }} (double: {{ double }})
  </button>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const double = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => {
  console.log('Mounted')
})
</script>
  • Logic is grouped by feature, not by option type.
  • Easy to extract into composables (useCounter).
  • Superior TypeScript experience.
  • Recommended default for new projects.

When to use which

  • New codebase: Composition API + <script setup> almost everywhere.
  • Legacy / small throwaway components: Options API is fine.
  • Migration path: Start with Options for existing components; migrate heavy logic into composables, then to <script setup> over time.

2.2 ref() vs reactive() vs computed() (TypeScript) #

ref<T>() – single reactive value #

  • Wraps a primitive or object in { value: T }.
  • In templates, .value is auto-unwrapped.
import { ref } from 'vue'

const count = ref<number>(0)
count.value++ // OK

const name = ref<string | null>(null)
name.value = 'Alice'

Template:

<template>
  <p>{{ count }}</p> <!-- .value auto-unwrapped -->
</template>

reactive<T>() – reactive objects/collections #

  • Wraps an object/array in a Proxy.
  • No .value; you mutate like a normal object.
import { reactive } from 'vue'

interface User {
  id: number
  name: string
  roles: string[]
}

const user = reactive<User>({
  id: 1,
  name: 'Alice',
  roles: ['admin']
})

user.name = 'Bob'           // reactive
user.roles.push('editor')   // reactive

Caveats:

  • Deep reactivity: nested objects are reactive too.
  • For reuse, prefer smaller refs + computed over sprawling reactive bags.

ref() vs reactive() for objects #

Both patterns work:

// 1. ref of object
const user = ref<User | null>(null)
user.value = { id: 1, name: 'Alice', roles: [] }

// 2. reactive object
const user2 = reactive<User>({
  id: 0,
  name: '',
  roles: []
})

Guidelines:

  • ref:
    • Great for primitives or optional objects (e.g. User | null).
    • More explicit for replacing the whole reference.
  • reactive:
    • Ergonomic for complex, nested objects (forms, config objects).
  • For arrays: choose either ref<T[]>() or reactive<T[]>([]) and be consistent.

computed<T>() – derived reactive values #

  • Like a cached getter that recomputes when dependencies change.
import { ref, computed } from 'vue'

const firstName = ref('Alice')
const lastName = ref('Smith')

const fullName = computed<string>(() => {
  return `${firstName.value} ${lastName.value}`
})

console.log(fullName.value)

Guidelines:

  • Use computed for pure, synchronous derivations.
  • Don’t put side effects (API calls, logging) inside computed. For that use watch/watchEffect.

2.3 Lifecycle hooks mapped to backend equivalents #

Key Composition API hooks:

  • onBeforeMount – before original mount.
  • onMounted – after first render is in DOM.
  • onBeforeUpdate – before a reactive update patches DOM.
  • onUpdated – after DOM patch.
  • onBeforeUnmount – before component is removed.
  • onUnmounted – after component is removed.
  • onErrorCaptured – when a child throws an error.
  • onActivated / onDeactivated – for <keep-alive> cached components.

Backend analogies

  • onMounted:
    • Like “controller initialized, first response done”.
    • Use it for initial data fetch, subscriptions, DOM integrations.
  • onUnmounted:
    • Like disposing a handler / background worker.
    • Use it to cleanup: unsubscribe, remove listeners, stop timers.
  • onUpdated:
    • Like “view re-rendered”; use sparingly (only for DOM libraries that need it).

Example:

import { ref, onMounted, onUnmounted } from 'vue'

function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  const onResize = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => {
    window.addEventListener('resize', onResize)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', onResize)
  })

  return { width, height }
}

2.4 Template directives: v-if, v-for, v-model #

v-if / v-else-if / v-else #

  • Conditional rendering: element may or may not exist in DOM.
<div v-if="user">
  Hello, {{ user.name }}
</div>
<div v-else>
  Not logged in
</div>
  • v-show is an alternative that toggles display: none but keeps the element in DOM.

v-for #

  • Loops over arrays/objects.
<li v-for="user in users" :key="user.id">
  {{ user.name }}
</li>

<li v-for="(value, key, index) in someObject" :key="key">
  {{ index }} - {{ key }}: {{ value }}
</li>

Always provide a stable :key for efficient diffing.

v-model #

On native inputs:

<input v-model="name" />

<!-- modifiers -->
<input v-model.trim="name" />
<input v-model.number="age" />
<input v-model.lazy="searchTerm" /> <!-- updates on change, not on every input -->

On components (default modelValue):

Child:

<!-- MyInput.vue -->
<template>
  <input :value="modelValue" @input="onInput" />
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function onInput(event: Event) {
  emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>

Parent:

<MyInput v-model="name" />

Custom model name:

Child:

const props = defineProps<{
  title: string
}>()

const emit = defineEmits<{
  'update:title': [value: string]
}>()

Parent:

<MyComponent v-model:title="pageTitle" />

2.5 Slots & scoped slots #

Think of slots as template partials/layout regions where the parent injects markup.

Basic slot:

<!-- Card.vue -->
<template>
  <div class="card">
    <slot /> <!-- default slot -->
  </div>
</template>

Use:

<Card>
  <h2>Title</h2>
  <p>Body content</p>
</Card>

Named slots:

<!-- Layout.vue -->
<template>
  <header>
    <slot name="header" />
  </header>
  <main>
    <slot />
  </main>
  <footer>
    <slot name="footer" />
  </footer>
</template>

Use:

<Layout>
  <template #header>
    <h1>My App</h1>
  </template>

  <template #default>
    <RouterView />
  </template>

  <template #footer>
    © 2025
  </template>
</Layout>

Scoped slots: child passes data up to parent-provided content.

Child:

<!-- UserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot name="item" :user="user">
        <!-- fallback if parent doesn’t provide slot -->
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const props = defineProps<{
  users: User[]
}>()
</script>

Parent:

<UserList :users="users">
  <template #item="{ user }">
    <strong>{{ user.name }}</strong>
  </template>
</UserList>

Conceptually similar to passing a callback into a renderer that receives context and returns markup.


2.6 Composables (like backend services/utilities) #

Composables = functions named useXxx that encapsulate state + logic and can be reused across components.

Example useCounter:

// useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return {
    count,
    double,
    increment
  }
}

Use in component:

<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'

const { count, double, increment } = useCounter(5)
</script>

<template>
  <button @click="increment">
    {{ count }} (double: {{ double }})
  </button>
</template>

For API/domain logic:

// useUser.ts
import { ref } from 'vue'
import { getUser } from '@/api/user'

export function useUser(userId: number) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function fetchUser() {
    loading.value = true
    error.value = null
    try {
      user.value = await getUser(userId)
    } catch (err) {
      error.value = err as Error
    } finally {
      loading.value = false
    }
  }

  // optionally auto-fetch
  fetchUser()

  return { user, loading, error, fetchUser }
}

This is closest to a service + query object from backend architectures.


3. MODERN PATTERNS & BEST PRACTICES #

3.1 Component composition patterns (avoiding prop drilling) #

Prop drilling = passing the same props through multiple layers just so a deeply nested child can use them.

Avoid with:

  1. Composables + shared dependencies

    • Deep components call the same composable instead of relying on props.
    // useCurrentUser.ts
    import { computed } from 'vue'
    import { useUserStore } from '@/stores/userStore'
    
    export function useCurrentUser() {
      const userStore = useUserStore()
      return computed(() => userStore.currentUser)
    }
  2. Provide / inject (see §3.6)

    • Hierarchical context: theme, layout config, multistep wizard state.
  3. Slots

    • Pass children content instead of encoding every variation in props.
    • Example: <Table> that exposes #cell / #header slots instead of endless prop permutations.
  4. State colocation

    • Keep state near the components that use it.
    • Only lift state when multiple siblings or routes truly need to coordinate.

3.2 State management: local vs Pinia #

Local component state

  • Use when:
    • Only the component (and maybe its immediate children) need the data.
    • Examples: modal open state, dropdown selection, unsaved form draft.

Pinia (global stores)

  • Official Vue 3 store solution (successor to Vuex).
  • Good when:
    • Distant components share state.
    • You need a single source of truth for domains:
      • Auth user, permissions.
      • Shopping cart.
      • Feature flags / configuration.
  • Integrates with DevTools, supports time-travel, persistence, plugins.

Example Pinia store:

// stores/userStore.ts
import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
}

interface UserState {
  currentUser: User | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    currentUser: null
  }),
  getters: {
    isAuthenticated: (state) => !!state.currentUser
  },
  actions: {
    setUser(user: User | null) {
      this.currentUser = user
    }
  }
})

Usage:

import { useUserStore } from '@/stores/userStore'

const userStore = useUserStore()

userStore.setUser({ id: 1, name: 'Alice' })
console.log(userStore.isAuthenticated) // true

Pinia vs TanStack Query

  • Pinia: client-state (UI flags, in-progress edits, client-only models).
  • TanStack Query: server-state (data whose source of truth is the backend and is cached client-side).

3.3 TypeScript integration: props, emits, generics #

Using <script setup lang="ts">:

Typed props

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const props = defineProps<{
  user: User
  editable?: boolean
}>()
</script>

Typed emits

const emit = defineEmits<{
  (e: 'save', user: User): void
  (e: 'cancel'): void
}>()

function onSave() {
  emit('save', props.user)
}

function onCancel() {
  emit('cancel')
}

TypeScript enforces event names and payload shapes.

Generic components (simplified)

<!-- GenericList.vue -->
<script setup lang="ts">
type WithId = { id: string | number }

const props = defineProps<{
  items: WithId[]
}>()
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :item="item">
        {{ item }}
      </slot>
    </li>
  </ul>
</template>

Type-safe Pinia

export interface CartItem {
  id: number
  title: string
  qty: number
}

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[]
  }),
  actions: {
    addItem(item: CartItem) {
      this.items.push(item)
    }
  }
})

items and addItem are fully typed.


3.4 Performance: watch vs watchEffect, memoization, virtual lists #

computed vs watch vs watchEffect #

  • computed:

    • Derived, pure values.
    • Cached; recomputes only when deps change.
  • watch(source, callback, options?):

    • For side effects based on specific reactive sources.
    • Access to newValue, oldValue.
    import { ref, watch } from 'vue'
    
    const query = ref('')
    const results = ref<string[]>([])
    
    watch(
      query,
      async (newQuery) => {
        if (newQuery.length < 3) {
          results.value = []
          return
        }
        results.value = await searchApi(newQuery)
      }
    )
  • watchEffect(effect, options?):

    • Like a “reactive effect”: automatically tracks any reactive values used inside.
    • Good for quick side effects spanning multiple sources.
    import { watchEffect } from 'vue'
    import { useUserStore } from '@/stores/userStore'
    
    const userStore = useUserStore()
    
    watchEffect(() => {
      console.log('Current user id:', userStore.currentUser?.id)
    })

Guidelines:

  • Prefer computed for derivations used in rendering or logic.
  • Use watch when you need precise triggers or previous values.
  • Use watchEffect for small, implicit, multi-source side effects.

Memoization #

  • computed is already memoized.
  • For heavy but pure transformations of reactive data, wrap them in computed.
  • For heavy computations over non-reactive data, use classic JS memoization (Maps, LRU caches, etc.).

Virtual lists #

Rendering thousands of nodes is slow. Use virtual scrolling:

  • Libraries like vue-virtual-scroller or similar.

Pattern:

<VirtualList
  :items="items"
  :item-size="48"
  v-slot="{ item }"
>
  <YourItemComponent :item="item" />
</VirtualList>

Only visible items are rendered to the DOM.


3.5 Error boundaries and logging #

Vue 3 options:

  1. Component-level error boundary (onErrorCaptured)

    <!-- ErrorBoundary.vue -->
    <template>
      <div v-if="error">
        <p>Something went wrong.</p>
        <pre v-if="debug">{{ error.stack }}</pre>
      </div>
      <slot v-else />
    </template>
    
    <script setup lang="ts">
    import { ref, onErrorCaptured } from 'vue'
    
    const props = defineProps<{
      debug?: boolean
    }>()
    
    const error = ref<Error | null>(null)
    
    onErrorCaptured((err) => {
      error.value = err as Error
      // return false to stop further propagation (optional)
      return false
    })
    </script>

    Usage:

    <ErrorBoundary>
      <DangerousComponent />
    </ErrorBoundary>
  2. Global error handler

    In main.ts:

    import { createApp } from 'vue'
    import App from './App.vue'
    
    const app = createApp(App)
    
    app.config.errorHandler = (err, instance, info) => {
      logError({
        message: (err as Error).message,
        stack: (err as Error).stack,
        component: instance?.$options.name,
        info
      })
    }
    
    app.mount('#app')
  3. Unhandled promise rejections

    • Catch in composables with try/catch.

    • Optionally:

      window.addEventListener('unhandledrejection', (event) => {
        logError({ type: 'unhandledrejection', reason: event.reason })
      })

Use Sentry, LogRocket, or your backend logging pipeline, ideally enriched with:

  • current route,
  • user id,
  • environment.

3.6 Dependency injection (provide / inject) #

Analogous to scoped DI containers/context.

Define an injection key

import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')

Provide in an ancestor component

import { ref, provide } from 'vue'
import { ThemeKey } from '@/di/keys'

const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)

Inject in any descendant

import { inject } from 'vue'
import { ThemeKey } from '@/di/keys'

const theme = inject(ThemeKey)
if (!theme) {
  throw new Error('Theme not provided')
}

theme.value = 'dark'

Use provide/inject for:

  • Cross-cutting concerns: theme, i18n, layout config.
  • Complex component trees (stepper, wizard) to share internal state.

Don’t use it as a replacement for global app state (Pinia is better for that).


4. ECOSYSTEM INTEGRATION #

4.1 Vue Router: navigation guards, lazy loading #

Basic setup with lazy-loaded routes

// router.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue') // lazy
  },
  {
    path: '/users/:id',
    name: 'user',
    component: () => import('@/views/UserView.vue'),
    meta: { requiresAuth: true }
  }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

In main.ts:

import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'

createApp(App)
  .use(router)
  .mount('#app')

In-component usage

import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

router.push({ name: 'user', params: { id: 123 } })
console.log(route.params.id)

Global navigation guard (auth)

// router.ts
import { useUserStore } from '@/stores/userStore'

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()

  if (to.meta.requiresAuth && !userStore.isAuthenticated) {
    return next({ name: 'login', query: { redirect: to.fullPath } })
  }

  next()
})

Per-route guard

const routes: RouteRecordRaw[] = [
  {
    path: '/admin',
    name: 'admin',
    component: () => import('@/views/AdminView.vue'),
    beforeEnter: (to, from, next) => {
      const userStore = useUserStore()
      if (!userStore.isAdmin) return next({ name: 'home' })
      next()
    }
  }
]

In-component guards (for unsaved changes)

import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const hasUnsavedChanges = ref(false)

onBeforeRouteLeave((to, from, next) => {
  if (hasUnsavedChanges.value) {
    const ignore = !window.confirm('Discard unsaved changes?')
    if (ignore) return next(false)
  }
  next()
})

4.2 API integration: TanStack Query vs axios #

axios: low-level HTTP client

// api/http.ts
import axios from 'axios'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  withCredentials: true
})

http.interceptors.request.use((config) => {
  const authStore = useAuthStore()
  if (authStore.token) {
    config.headers = config.headers ?? {}
    config.headers.Authorization = `Bearer ${authStore.token}`
  }
  return config
})

http.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      useAuthStore().logout()
    }
    return Promise.reject(error)
  }
)

TanStack Query (Vue Query): server-state manager

Handles:

  • Caching.
  • Deduped requests.
  • Background refetch.
  • Stale vs fresh data.

Setup:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { VueQueryPlugin, type VueQueryPluginOptions } from '@tanstack/vue-query'

const vueQueryOptions: VueQueryPluginOptions = {
  queryClientConfig: {
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        retry: 1
      }
    }
  }
}

createApp(App)
  .use(VueQueryPlugin, vueQueryOptions)
  .mount('#app')

Define a query hook:

// queries/useUserQuery.ts
import { useQuery } from '@tanstack/vue-query'
import { http } from '@/api/http'

export interface User {
  id: number
  name: string
}

function fetchUser(id: number): Promise<User> {
  return http.get(`/users/${id}`).then((res) => res.data as User)
}

export function useUserQuery(id: number) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id)
  })
}

Use in a component:

<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useUserQuery } from '@/queries/useUserQuery'

const route = useRoute()
const userId = Number(route.params.id)

const { data: user, isLoading, error } = useUserQuery(userId)
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="error">Failed to load user.</div>
  <div v-else>
    {{ user.name }}
  </div>
</template>

When to use what

  • Simple apps / limited endpoints:
    • axios (or fetch) inside composables is fine.
  • Larger apps with lots of shared server data:
    • axios (or fetch) + TanStack Query for server-state.
    • Pinia for client-state.

4.3 Form handling: VeeValidate / FormKit with backend validation sync #

VeeValidate #

  • Form handling + validation library with schema support (Yup/Zod, etc.).

Example:

<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  email: yup.string().required().email(),
  password: yup.string().required().min(8)
})

async function onSubmit(values: { email: string; password: string }) {
  try {
    await login(values)
  } catch (e: any) {
    // Option 1: throw object with field-level errors:
    // throw { email: 'Email already taken' }
    // Option 2: use setFieldError (see VeeValidate docs)
  }
}
</script>

<template>
  <Form :validation-schema="schema" @submit="onSubmit">
    <div>
      <label>Email</label>
      <Field name="email" type="email" />
      <ErrorMessage name="email" />
    </div>

    <div>
      <label>Password</label>
      <Field name="password" type="password" />
      <ErrorMessage name="password" />
    </div>

    <button type="submit">Login</button>
  </Form>
</template>

Sync with backend validation

Define error response like:

{
  "errors": {
    "email": ["Email is already taken"],
    "password": ["Password too weak"]
  }
}

Then, in onSubmit, catch the error and map these to field errors using VeeValidate APIs.

FormKit #

  • Higher-level, schema-driven form system.
  • Good for complex or dynamic forms.

Simple example:

<FormKit
  type="form"
  v-model="formData"
  :actions="false"
  @submit="onSubmit"
>
  <FormKit
    name="email"
    type="email"
    label="Email"
    validation="required|email"
  />

  <FormKit
    name="password"
    type="password"
    label="Password"
    validation="required|length:8"
  />

  <button type="submit">Submit</button>
</FormKit>

Concept is similar: map backend field errors into FormKit’s error system.


4.4 Testing: Vitest + Testing Library #

  • Vitest: Vite-native test runner (Jest-like API).
  • Vue Testing Library: Testing Library for Vue (focus on user-centric tests).

Component:

<!-- Counter.vue -->
<template>
  <button @click="increment">{{ count }}</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

Test:

// Counter.spec.ts
import { render, fireEvent, screen } from '@testing-library/vue'
import Counter from './Counter.vue'
import { describe, it, expect } from 'vitest'

describe('Counter', () => {
  it('increments on click', async () => {
    render(Counter)

    const button = screen.getByRole('button')
    expect(button.textContent).toBe('0')

    await fireEvent.click(button)
    expect(button.textContent).toBe('1')
  })
})

Testing a composable:

// useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'

describe('useCounter', () => {
  it('increments', () => {
    const { count, increment } = useCounter(0)
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })
})

Patterns:

  • Test components by behavior and rendered output, not internal refs.
  • Wrap tests with providers (router, Pinia, Vue Query) as needed.

4.5 DevTools and debugging techniques #

Vue DevTools

  • Inspect component tree.
  • View props, state, computed values.
  • Time-travel debugging for Pinia.
  • Inspect router and Vuex/Pinia/Query plugins.

Debugging tips

  • Use browser DevTools:

    • Place debugger inside setup or composables.
    • Vite’s source maps let you debug .vue SFCs and TS.
  • Enable performance markers (dev only):

    import { createApp } from 'vue'
    import App from './App.vue'
    
    const app = createApp(App)
    app.config.performance = true
    app.mount('#app')
  • Common reactivity gotchas:

    • Changing a non-reactive object won’t trigger updates:
      • Ensure you used ref/reactive to create state.
    • Replacing a whole reactive object:
      • With reactive, prefer mutating properties instead of reassigning the variable.
      • Or use ref for the object if you need to replace it wholesale.

defineExpose for introspection

<!-- Child.vue -->
<script setup lang="ts">
import { ref, defineExpose } from 'vue'

const count = ref(0)

defineExpose({ count })
</script>

Parent (in dev console):

  • Get a ref to child (ref="childRef").
  • Inspect childRef.value.count.

Linting & tooling

  • ESLint + Prettier + TypeScript:
    • eslint-plugin-vue
    • @vue/eslint-config-typescript
  • Vite + @vitejs/plugin-vue:
    • Fast HMR and excellent DX for Vue 3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment