Skip to main content

Vue 3 Cheatsheet — Composition API, Reactivity, Components, Directives, Pinia, with Options API Comparison

Vue 3 cheat sheet — Composition API, reactivity, components, directives, Pinia, with side-by-side Options API comparison.

  • Runs locally
  • Category Developer & DevOps
  • Best for Formatting, validating, shrinking, or inspecting code-adjacent text.
132 snippets
Reactivity (21)

ref — primitive reactive value

Reactivity
import { ref } from 'vue';
const count = ref(0);
// inside <script setup> the template auto-unwraps:  {{ count }}
// in JS code you must use .value :
count.value++;
console.log(count.value);  // 1

What it does:ref wraps a value in a reactive container. You read and write it through `.value` in JS; in the template Vue auto-unwraps the top-level ref so `{{ count }}` works without `.value`. Use ref for primitives (number / string / boolean) and any value you might want to REPLACE wholesale.

Options API equivalent
// Options API
export default {
  data() { return { count: 0 }; },
  methods: { inc() { this.count++; } }
};

reactive — deep-reactive object

Reactivity
import { reactive } from 'vue';
const state = reactive({ user: { name: 'Lei', age: 30 }, tags: ['vue'] });
state.user.age = 31;     // tracked
state.tags.push('web');  // tracked (arrays too)

What it does:reactive returns a Proxy that tracks every property at every depth. Best for OBJECT state you mutate in place. It only works on objects / arrays / Map / Set — primitives must use ref. Destructuring a reactive object loses reactivity; use toRefs to keep it.

Options API equivalent
data() { return { user: { name: 'Lei', age: 30 }, tags: ['vue'] }; }

computed — derived reactive value

Reactivity
import { ref, computed } from 'vue';
const price = ref(100);
const qty = ref(3);
const total = computed(() => price.value * qty.value);
// writable computed
const fullName = computed({
  get: () => first.value + ' ' + last.value,
  set: (v: string) => { [first.value, last.value] = v.split(' '); }
});

What it does:computed lazily caches a derived value and only re-evaluates when one of its reactive dependencies changes. Default form is read-only; pass `{ get, set }` to make it two-way. Prefer computed over methods in templates — methods re-run every render, computed does not.

Options API equivalent
computed: {
  total() { return this.price * this.qty; }
}

watch — explicit source + callback

Reactivity
import { ref, watch } from 'vue';
const id = ref(1);

watch(id, (newId, oldId, onCleanup) => {
  const ctrl = new AbortController();
  fetch(`/api/user/${newId}`, { signal: ctrl.signal }).then(/*…*/);
  onCleanup(() => ctrl.abort());  // cancel stale request
});

// watch multiple sources
watch([first, last], ([f, l]) => console.log(f, l));

// deep watch on a reactive object
watch(() => state.user, (u) => console.log(u), { deep: true });

What it does:watch is lazy by default — the callback runs only AFTER the source changes (not on creation). Source can be a ref, a getter, a reactive object, or an array of any of these. The third callback arg is `onCleanup` for cancelling stale async work. Pass `{ immediate: true }` to fire once on mount, `{ deep: true }` to track nested mutations.

Options API equivalent
watch: {
  id(newId, oldId) { /* … */ }
}

watchEffect — auto-track everything read

Reactivity
import { ref, watchEffect } from 'vue';
const url = ref('/api/me');
watchEffect(() => {
  // any ref read inside is tracked automatically
  fetch(url.value).then(/*…*/);
});
// stop manually if you need to
const stop = watchEffect(() => console.log(count.value));
stop();

What it does:watchEffect runs the effect once synchronously, tracking every reactive dependency it reads, and re-runs whenever any of those dependencies change. No explicit source list — like the React analog "useEffect with no deps array, auto-tracked". Returns a stop function. Use it for synchronization side effects; reach for watch when you need access to the previous value.

shallowRef — only top-level reactive

Reactivity
import { shallowRef, triggerRef } from 'vue';
const big = shallowRef({ items: largeArray });

// replacing .value still triggers
big.value = { items: newArray };

// mutating nested fields does NOT trigger
big.value.items.push(1);  // no re-render
triggerRef(big);           // force update

What it does:shallowRef tracks reassignment to `.value` but does NOT track nested mutations. Use it for large objects (a Map of 10k entries, a 3D scene, an editor doc) where deep proxying is expensive — you replace the value wholesale or call triggerRef to flush. Saves both memory and CPU on heavy structures.

shallowReactive — only first-level reactive

Reactivity
import { shallowReactive } from 'vue';
const state = shallowReactive({
  user: { name: 'Lei' },   // user object itself is NOT reactive
  count: 0                 // count IS reactive (top-level primitive)
});
state.count++;             // tracked
state.user.name = 'Hong';  // NOT tracked
state.user = { name: 'Hong' };  // tracked — replacing the property is top-level

What it does:shallowReactive proxies only the first-level properties. Nested objects are returned as-is — mutating them does not trigger updates, but replacing a top-level key does. Use it when you keep large already-frozen objects inside reactive state and only the top-level pointer matters.

readonly — immutable view of reactive data

Reactivity
import { reactive, readonly } from 'vue';
const original = reactive({ count: 0 });
const copy = readonly(original);

original.count++;        // works, copy.count also updates
copy.count++;            // warns in dev, no-op
// pass readonly to children that should not mutate parent state
provide('config', readonly(original));

What it does:readonly returns a proxy whose properties cannot be mutated — attempting to write logs a dev-time warning. Reads still see live updates from the original. Perfect for provide/inject so children can read parent state but cannot modify it, enforcing one-way data flow.

toRefs — destructure without losing reactivity

Reactivity
import { reactive, toRefs } from 'vue';
const state = reactive({ x: 1, y: 2 });

// WRONG — destructuring breaks reactivity
const { x, y } = state;

// RIGHT — each becomes a ref pointing into the proxy
const { x: xRef, y: yRef } = toRefs(state);
xRef.value++;            // state.x is also 2
// useful when returning from a composable:
function useMouse() {
  const pos = reactive({ x: 0, y: 0 });
  return toRefs(pos);    // caller can destructure
}

What it does:toRefs converts each property of a reactive object into a ref that POINTS into the proxy — writes propagate both ways. The canonical fix for "I destructured my reactive state and it stopped updating". Composables should return toRefs so the caller can destructure ergonomically.

toRef — single property to ref

Reactivity
import { reactive, toRef } from 'vue';
const state = reactive({ count: 0 });
const countRef = toRef(state, 'count');
countRef.value++;        // state.count is also 1

// 3.3+ getter-based form
const upper = toRef(() => state.name.toUpperCase());

What it does:toRef creates a ref that points at a single property of a reactive object — same purpose as toRefs but for one key. Vue 3.3+ also accepts a getter so you can wrap any computed expression as a ref to pass around. Avoids the cost of converting every key when you only need one.

isRef / unref — type guards

Reactivity
import { isRef, unref, ref } from 'vue';
function useDouble(x: number | Ref<number>) {
  // unref returns x if it is a ref, else x itself
  return computed(() => unref(x) * 2);
}
const a = ref(10);
isRef(a);     // true
unref(a);     // 10
unref(99);    // 99

What it does:isRef tells you whether a value is a ref. unref is the "just give me the value, ref or not" helper — equivalent to `isRef(x) ? x.value : x`. Useful in composables that accept "value or ref" arguments so the caller can pass either.

customRef — build your own tracker

Reactivity
import { customRef } from 'vue';
function useDebouncedRef<T>(value: T, delay = 300) {
  let timer: number;
  return customRef<T>((track, trigger) => ({
    get() { track(); return value; },
    set(newValue) {
      clearTimeout(timer);
      timer = setTimeout(() => { value = newValue; trigger(); }, delay);
    }
  }));
}
const text = useDebouncedRef('', 500);

What it does:customRef exposes the low-level track / trigger primitives so you can build your own reactive value with custom timing — debounce, throttle, validation, anything. Vue calls `track()` when something reads `.value` and `trigger()` when you want to invalidate readers. Rarely needed in app code, useful for library authors.

watch on getter vs ref source

Reactivity
import { ref, reactive, watch } from 'vue';
const r = ref(0);
const state = reactive({ n: 0 });

// ref source — pass the ref itself
watch(r, (val) => console.log(val));

// getter source — pass () => expression
watch(() => state.n, (val) => console.log(val));

// getter returning multiple values needs an array source or object
watch(() => ({ ...state }), (snap) => console.log(snap));

What it does:A watch source can be a ref (pass directly), a getter `() => expr` (for any computed expression or a single reactive property), a reactive object (deep watch implied), or an array of these. The getter form is what you reach for to watch one field of a reactive object — passing the property value directly captures a snapshot, not a tracked source.

watch flush timing — pre / post / sync

Reactivity
import { ref, watch, nextTick } from 'vue';
const list = ref<number[]>([]);

// default flush: 'pre' — runs BEFORE the component re-renders
watch(list, () => { /* DOM still old here */ });

// flush: 'post' — runs AFTER the DOM is updated (read updated DOM)
watch(list, () => { measureRenderedList(); }, { flush: 'post' });

// flush: 'sync' — runs synchronously on every change (use sparingly)
watch(list, () => { /* fires immediately, no batching */ }, { flush: 'sync' });

What it does:The `flush` option controls when a watch callback runs relative to the render cycle. Default `pre` fires before the component re-renders (the DOM is still the old version). `post` fires after Vue patches the DOM, so you can measure the updated layout (equivalent to wrapping in `nextTick`). `sync` fires synchronously on each change with no batching — expensive, reserve for special cases.

effectScope — group and dispose effects

Reactivity
import { effectScope, ref, watch, watchEffect } from 'vue';

const scope = effectScope();
scope.run(() => {
  const count = ref(0);
  watch(count, () => { /* … */ });
  watchEffect(() => { /* … */ });
});

// dispose every effect created inside the scope at once
scope.stop();

What it does:effectScope captures every reactive effect (watch / watchEffect / computed) created inside its `run` callback so you can dispose them all with a single `scope.stop()`. Useful for composables that create effects outside a component setup, or for libraries that manage their own reactive lifecycle independent of a component instance.

markRaw — opt an object out of reactivity

Reactivity
import { markRaw, reactive } from 'vue';
import { Chart } from 'chart.js';

const state = reactive({
  // a third-party instance you never want proxied
  chart: markRaw(new Chart(/* … */)),
  config: { theme: 'dark' },
});
// state.chart stays the raw instance, methods like .update() keep working

What it does:markRaw flags an object so Vue never wraps it in a reactive proxy, even when nested inside reactive state. Use it for large immutable data, class instances from third-party libraries (chart objects, map instances, editor docs) where proxying would break internal `this` checks or waste memory. Once marked, it cannot be un-marked.

toRaw — get the original behind a proxy

Reactivity
import { reactive, toRaw } from 'vue';
const state = reactive({ list: [1, 2, 3] });

const raw = toRaw(state);
// raw is the plain non-reactive object — mutating it does NOT trigger updates
console.log(raw === state);  // false (state is a Proxy)

// common use: pass a clean object to a structuredClone / JSON / worker
const snapshot = structuredClone(toRaw(state));

What it does:toRaw returns the original object that a reactive / readonly / shallowReactive proxy wraps. Reads through the raw object are not tracked and writes do not trigger updates. Reach for it when you need to hand a plain object to an API that chokes on proxies (structuredClone, IndexedDB, postMessage to a worker) — but never store the raw object back as your source of truth.

computed with previous value (3.4+)

Reactivity
import { ref, computed } from 'vue';
const count = ref(2);

// the getter receives the previous computed result
const clamped = computed((prev) => {
  if (count.value <= 3) return count.value;
  // keep the last valid value when out of range
  return prev as number;
});

What it does:Since Vue 3.4 the computed getter receives the previous computed value as its first argument. Use it to keep the last valid result when the new input is out of range, or to build a value that depends partly on its own history (a clamped scroll position, a debounced display value) without an extra ref.

isReactive / isReadonly / isProxy

Reactivity
import { reactive, readonly, isReactive, isReadonly, isProxy } from 'vue';
const r = reactive({ x: 1 });
const ro = readonly(r);

isReactive(r);     // true
isReadonly(ro);    // true
isReactive(ro);    // true (readonly of a reactive is still reactive)
isProxy(r);        // true
isProxy({});       // false

What it does:These guards report what kind of proxy (if any) a value is. isReactive is true for reactive / shallowReactive proxies, isReadonly for readonly / shallowReadonly, and isProxy is true for any of them. Mostly useful in library code or generic utilities that must branch on whether they were handed a raw object or a Vue proxy.

triggerRef — manually flush a shallowRef

Reactivity
import { shallowRef, triggerRef } from 'vue';
const state = shallowRef({ count: 0 });

// nested mutation does NOT auto-trigger on a shallowRef
state.value.count++;
triggerRef(state);   // force dependents to re-run now

What it does:triggerRef forces effects depending on a shallowRef (or a customRef) to re-run, even though no reassignment of `.value` happened. Use it after you deliberately mutate the inside of a shallowRef for performance reasons — you skipped deep reactivity, so you take responsibility for telling Vue when to update. Pointless on a normal `ref`, which already tracks deep changes.

watchPostEffect / watchSyncEffect

Reactivity
import { watchPostEffect, watchSyncEffect } from 'vue';

// shorthand for watchEffect(fn, { flush: 'post' })
watchPostEffect(() => {
  // runs AFTER DOM update — read measured layout safely
});

// shorthand for watchEffect(fn, { flush: 'sync' })
watchSyncEffect(() => {
  // runs synchronously on each dependency change
});

What it does:These are flush-timing shorthands for watchEffect. `watchPostEffect` is `watchEffect(fn, { flush: "post" })` — the effect runs after the DOM is patched, so it is the right place to read updated element sizes or positions. `watchSyncEffect` is the `sync` variant that fires synchronously per change with no batching; use it sparingly for cases that genuinely cannot tolerate the microtask delay.

Lifecycle (11)

onMounted — after first render to DOM

Lifecycle
import { onMounted, ref } from 'vue';
const elRef = ref<HTMLDivElement | null>(null);

onMounted(() => {
  // DOM is ready, refs are attached
  console.log(elRef.value?.offsetHeight);
  const obs = new ResizeObserver(/*…*/);
  obs.observe(elRef.value!);
});

What it does:Called once after the component is mounted and its DOM is in the document. The template ref is non-null here — read it for DOM measurement, set up observers, third-party DOM libraries. Do NOT use for one-time data fetching that does not need the DOM (call it directly in setup so it streams during SSR).

Options API equivalent
mounted() { /* … */ }

onUpdated — after every re-render

Lifecycle
import { onUpdated, ref } from 'vue';
const listRef = ref<HTMLUListElement | null>(null);

onUpdated(() => {
  // runs after EVERY update — keep it cheap
  listRef.value?.scrollTo({ top: listRef.value.scrollHeight });
});

What it does:Fires after the component re-renders due to reactive state change. Useful for syncing DOM that depends on the latest render (auto-scroll a chat to bottom, focus a freshly-inserted input). Runs on every update so keep the work minimal — prefer watch on a specific value when you can.

Options API equivalent
updated() { /* … */ }

onUnmounted — before removal from DOM

Lifecycle
import { onMounted, onUnmounted } from 'vue';
onMounted(() => {
  const id = setInterval(tick, 1000);
  onUnmounted(() => clearInterval(id));   // colocated cleanup
});

// or pair with the listener
const onResize = () => { /* … */ };
onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));

What it does:Runs when the component is being unmounted. Tear down everything you created in onMounted: timers, subscriptions, global listeners, ResizeObservers. Forgetting this is the #1 source of "my SPA gets slower the longer it runs" bugs.

Options API equivalent
beforeUnmount() { /* … */ }

onBeforeMount — before first render

Lifecycle
import { onBeforeMount } from 'vue';
onBeforeMount(() => {
  // reactive state is ready, but DOM is NOT yet rendered
  // refs are still null — DO NOT read them here
  console.log('about to mount');
});

What it does:Called right before the first render — reactive state is initialized but no DOM exists yet. Template refs are still null. Rarely needed in app code; most "before mount" intent belongs directly in `<script setup>` body, which is itself "before mount".

Options API equivalent
beforeMount() { /* … */ }

onBeforeUpdate — before re-render

Lifecycle
import { onBeforeUpdate, ref } from 'vue';
const listRef = ref<HTMLUListElement | null>(null);
let prevScroll = 0;

onBeforeUpdate(() => {
  // DOM is still pre-update — measure the old state
  prevScroll = listRef.value?.scrollTop ?? 0;
});

What it does:Fires before Vue patches the DOM for a reactive update. The DOM still shows the PREVIOUS render. Use to capture pre-update measurements you will reapply after the patch (preserve scroll position when prepending items, save focus before list shuffle).

Options API equivalent
beforeUpdate() { /* … */ }

onBeforeUnmount — last chance before teardown

Lifecycle
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => {
  // DOM and reactive state are still alive
  // send the draft, save scroll position, etc.
  saveDraftToServer();
});

What it does:Called immediately before unmount — DOM and reactive state are still intact, refs still point at live elements. Last chance to read state for analytics, save unsaved input, or fire fire-and-forget cleanup. onUnmounted runs AFTER detachment.

Options API equivalent
beforeUnmount() { /* … */ }

onErrorCaptured — child error boundary

Lifecycle
import { onErrorCaptured, ref } from 'vue';
const errorMsg = ref<string | null>(null);

onErrorCaptured((err, instance, info) => {
  errorMsg.value = err.message;
  console.error('caught from child:', info);
  return false;   // stop propagation; return true / undef to bubble
});

What it does:Catches errors thrown by descendant components (render, lifecycle hooks, watch callbacks, setup). Return `false` to stop the error from bubbling up to the global app.config.errorHandler. The closest Vue analog to React error boundaries — wrap risky subtrees in a component that registers this hook.

Options API equivalent
errorCaptured(err, vm, info) { /* … */ }

onActivated / onDeactivated — for <KeepAlive>

Lifecycle
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  // entered from a kept-alive cache — refresh data, resume timers
  refreshList();
});
onDeactivated(() => {
  // cached, not destroyed — pause work, save scroll
  pausePolling();
});

What it does:When a component is wrapped in <KeepAlive>, it is not destroyed on hide — it is parked. onActivated fires every time it re-enters the screen, onDeactivated every time it is hidden. Use to resume polling / refresh stale data on activate, pause work on deactivate.

Options API equivalent
activated() { /* … */ }, deactivated() { /* … */ }

nextTick — wait for the DOM to update

Lifecycle
import { ref, nextTick } from 'vue';
const show = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);

async function reveal() {
  show.value = true;
  // DOM has NOT updated yet on this line
  await nextTick();
  // now the v-if element exists — safe to focus
  inputRef.value?.focus();
}

What it does:Vue batches reactive changes and flushes them on the next microtask, so the DOM is not updated synchronously after you mutate state. nextTick returns a promise that resolves once the pending DOM update has been applied. Await it before measuring or focusing an element that a state change just rendered.

onServerPrefetch — fetch during SSR

Lifecycle
import { ref, onServerPrefetch } from 'vue';
const data = ref<User | null>(null);

onServerPrefetch(async () => {
  // runs on the server before HTML is serialized
  data.value = await fetchUser();
});

// on the client, fetch in onMounted instead if data is empty

What it does:onServerPrefetch registers an async hook that runs on the server during SSR, before the component is serialized to HTML — letting you await data so it ships in the initial markup. It does not run on the client. In `<script setup>` a top-level await under <Suspense> covers the same need more concisely; reach for this hook when you cannot make setup async.

onRenderTracked / onRenderTriggered (dev)

Lifecycle
import { onRenderTracked, onRenderTriggered } from 'vue';

// which deps the render tracked (fires on first render + updates)
onRenderTracked((e) => {
  console.log('tracked', e.type, e.key);
});

// which dep change caused THIS re-render
onRenderTriggered((e) => {
  console.log('triggered by', e.type, e.key, e.target);
});

What it does:Two dev-only debug hooks. onRenderTracked fires for each reactive dependency the render reads (helps spot accidental dependencies). onRenderTriggered fires when a tracked dependency change causes a re-render, reporting exactly which key changed — the fastest way to answer "why did this component re-render?". Both are no-ops in production builds.

Component (20)

defineComponent — typed component factory

Component
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'UserCard',
  props: { id: { type: Number, required: true } },
  setup(props) {
    // props.id is typed as number
    return { /* … */ };
  }
});

What it does:Wraps an options-object component definition so TypeScript can infer props / emits types. Only needed in `.ts` files or when you author components with the options object (not `<script setup>`). Inside `<script setup>` you do not need defineComponent — it is implicit.

defineProps — declare props in <script setup>

Component
<script setup lang="ts">
// runtime declaration
const props = defineProps({
  id: { type: Number, required: true },
  tags: { type: Array as PropType<string[]>, default: () => [] }
});

// TS type-only declaration (3.3+: supports external types)
const props = defineProps<{
  id: number;
  tags?: string[];
}>();

// 3.3+: with defaults via destructure
const { id, tags = [] } = defineProps<{ id: number; tags?: string[] }>();
</script>

What it does:Compile-time macro for declaring props inside `<script setup>`. Two forms: runtime (`defineProps({...})`) gives you Vue runtime validation; type-only (`defineProps<{...}>()`) gives you pure TS types — no runtime check. Vue 3.3+ supports importing external types and destructuring props with defaults that stay reactive.

defineEmits — declare events

Component
<script setup lang="ts">
// type-only — recommended
const emit = defineEmits<{
  (e: 'select', id: number): void;
  (e: 'close'): void;
}>();

// 3.3+: object syntax (shorter)
const emit = defineEmits<{
  select: [id: number];
  close: [];
}>();

emit('select', 42);
</script>

What it does:Compile-time macro that declares which events the component emits and, with the TS form, what payload each carries. The returned `emit` function is fully typed — wrong event names or wrong payload shapes are TypeScript errors. The 3.3+ tuple form (`select: [id: number]`) is shorter than the call-signature form.

defineExpose — expose to parent ref

Component
<!-- Child.vue -->
<script setup lang="ts">
const open = () => { /* … */ };
defineExpose({ open });   // parent can call childRef.value?.open()
</script>

<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue';
const childRef = ref<InstanceType<typeof Child> | null>(null);
const handleClick = () => childRef.value?.open();
</script>
<template><Child ref="childRef" /></template>

What it does:In `<script setup>` everything is closed by default — the parent template ref sees nothing. defineExpose explicitly publishes selected functions or values to the parent. Use sparingly: prefer props down / events up. Reach for it for imperative APIs (open / focus / scrollIntoView) that do not fit a declarative shape.

defineModel — two-way binding (3.4+)

Component
<!-- Child.vue (3.4+) -->
<script setup lang="ts">
const model = defineModel<string>();           // v-model default
const title = defineModel<string>('title');    // v-model:title
const search = defineModel<string>('search', { required: true });
</script>
<template>
  <input v-model="model" />
</template>

<!-- Parent.vue -->
<Child v-model="text" v-model:title="t" />

What it does:The 3.4+ shorthand for two-way binding. defineModel returns a ref — write to it and the parent v-model updates, read it for the current parent value. Replaces the old prop+emit boilerplate (`modelValue` + `update:modelValue`). Multiple v-models via named arguments (`defineModel("title")`).

defineOptions — set options inside setup

Component
<script setup lang="ts">
defineOptions({
  name: 'UserCard',          // for DevTools + recursion
  inheritAttrs: false,       // don't auto-bind $attrs to root
});
</script>

What it does:Compile-time macro (3.3+) to set component-level options that previously required a second `<script>` block — `name`, `inheritAttrs`, `customOptions`. Lets you keep everything in one `<script setup>` instead of mixing options-API and setup.

defineSlots — typed slot signatures

Component
<script setup lang="ts">
defineSlots<{
  default(props: { item: User }): any;
  header?(props: { count: number }): any;
}>();
</script>
<template>
  <header><slot name="header" :count="items.length" /></header>
  <slot v-for="i in items" :item="i" :key="i.id" />
</template>

What it does:Compile-time macro (3.3+) for declaring the slot prop signatures of a component, giving the parent full TypeScript inference on `<template v-slot:default="{ item }">`. Until 3.3, slot props were essentially `any` — defineSlots closes that gap.

withDefaults — defaults for type-only props

Component
<script setup lang="ts">
interface Props { size?: 'sm' | 'md' | 'lg'; tags?: string[]; }
const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  tags: () => []     // object/array defaults MUST be functions
});
</script>

What it does:When you use the type-only form `defineProps<{...}>()` you lose Vue's default-value mechanism. withDefaults wraps it to add them back. Defaults for objects / arrays MUST be returned from a function — otherwise every instance shares the same reference. (Vue 3.5+ the destructure-with-default form supersedes withDefaults in most cases.)

defineAsyncComponent — lazy load

Component
import { defineAsyncComponent } from 'vue';

const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));

// with loading / error UI
const Editor = defineAsyncComponent({
  loader: () => import('./Editor.vue'),
  loadingComponent: Spinner,
  errorComponent: ErrorBox,
  delay: 200,
  timeout: 10_000
});

What it does:Wraps a dynamic import in a placeholder component that suspends until the chunk arrives. Pair with router lazy routes for code-splitting; or use the object form to render a spinner after `delay` ms and an error box after `timeout`. The full form also supports retry via the `onError` hook.

<Suspense> — async setup boundary

Component
<template>
  <Suspense>
    <template #default>
      <!-- component whose setup() is async -->
      <UserProfile :id="userId" />
    </template>
    <template #fallback>
      <div>Loading…</div>
    </template>
  </Suspense>
</template>

<!-- UserProfile.vue -->
<script setup lang="ts">
const user = await fetch('/api/me').then(r => r.json());
</script>

What it does:Boundary component that waits for descendant components with `async setup()` (or top-level await in `<script setup>`) before showing its default slot — the fallback slot shows in the meantime. Still flagged as experimental but production-ready for most apps. Pair with defineAsyncComponent for "lazy chunk + async data" in one boundary.

<KeepAlive> — cache toggled components

Component
<template>
  <KeepAlive :include="['UserList', 'UserDetail']" :max="10">
    <component :is="currentTab" />
  </KeepAlive>
</template>

What it does:Caches `<component :is="…">` instances when they switch out instead of unmounting them, so state and DOM are preserved on switch back. `include` / `exclude` whitelist by component name; `max` caps the LRU. Pair with onActivated / onDeactivated to refresh stale data on re-entry.

<Teleport> — render somewhere else in the DOM

Component
<template>
  <button @click="open = true">Open</button>
  <Teleport to="body">
    <div v-if="open" class="modal-overlay">
      <div class="modal">Dialog content</div>
    </div>
  </Teleport>
</template>

<!-- conditionally disable to render in place -->
<Teleport to="#drawer" :disabled="isMobile">…</Teleport>

What it does:Teleport moves its slot content to a different place in the real DOM (a CSS selector or element) while keeping it logically a child of the current component — props, events and provide/inject all still flow normally. The classic use is modals and toasts that must escape an `overflow:hidden` or `z-index` ancestor by mounting under `body`. `:disabled` renders it in place when you need that.

<Transition> — single-element enter/leave

Component
<template>
  <Transition name="fade">
    <p v-if="show">hello</p>
  </Transition>
</template>

<style>
.fade-enter-active, .fade-leave-active { transition: opacity .3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

What it does:Transition animates a single element / component as it is inserted or removed via v-if, v-show, dynamic component, or route change. You give it a `name`; Vue toggles six CSS classes (`-enter-from`, `-enter-active`, `-enter-to`, and the leave trio) at the right moments so you write the animation in plain CSS. Pair with `mode="out-in"` to fully sequence one element out before the next comes in.

<TransitionGroup> — animate list changes

Component
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">{{ item.text }}</li>
  </TransitionGroup>
</template>

<style>
.list-enter-from, .list-leave-to { opacity: 0; transform: translateY(8px); }
.list-enter-active, .list-leave-active { transition: all .3s ease; }
.list-move { transition: transform .3s ease; }  /* smooth reorder */
</style>

What it does:TransitionGroup animates a list rendered with v-for — items animate in / out on add / remove, and the special `-move` class smoothly slides siblings to their new positions when the list reorders (using the FLIP technique). It renders a real wrapper element via `tag`. Every child must have a unique `:key` for the move animation to track items correctly.

Dynamic component — <component :is>

Component
<script setup lang="ts">
import Foo from './Foo.vue';
import Bar from './Bar.vue';
import { shallowRef } from 'vue';
const current = shallowRef(Foo);   // shallowRef, not ref
</script>
<template>
  <component :is="current" :some-prop="x" @evt="onEvt" />
  <!-- :is also accepts a registered name string or an HTML tag -->
  <component :is="ok ? 'a' : 'span'">link</component>
</template>

What it does:The built-in `<component>` renders whatever its `:is` resolves to — a component definition, a globally-registered name string, or even a plain HTML tag name. Props and events pass through normally. Hold the component reference in a `shallowRef` (not `ref`) so Vue does not needlessly deep-proxy the component object. Wrap in <KeepAlive> to preserve state across switches.

Recursive component — self reference

Component
<!-- TreeNode.vue -->
<script setup lang="ts">
defineProps<{ node: { label: string; children?: any[] } }>();
defineOptions({ name: 'TreeNode' });   // name enables self-reference
</script>
<template>
  <li>
    {{ node.label }}
    <ul v-if="node.children?.length">
      <TreeNode v-for="c in node.children" :key="c.id" :node="c" />
    </ul>
  </li>
</template>

What it does:A component can render itself to display recursive data like trees and comment threads. In SFCs the file name (or an explicit `name` via defineOptions) lets the template reference the component by name. Always guard the recursion with a condition (`v-if="node.children"`) so it terminates — an unbounded self-call blows the stack.

Global vs local component registration

Component
// global — available in every template, but always in the bundle
import { createApp } from 'vue';
import BaseButton from './BaseButton.vue';
const app = createApp(App);
app.component('BaseButton', BaseButton);

// local — import where used, tree-shakeable (preferred)
<script setup>
import BaseButton from './BaseButton.vue';  // just use it in template
</script>

What it does:Global registration (`app.component`) makes a component usable in any template without importing, but it stays in the bundle whether used or not and hurts tree-shaking — reserve it for a handful of truly app-wide base components. Local registration (importing into `<script setup>`) is the default: explicit, tree-shakeable, and friendlier to tooling and IDE navigation.

Fallthrough attributes — $attrs

Component
<!-- Child with a single root: attrs auto-apply to it -->
<template>
  <button class="btn"><slot /></button>
  <!-- parent's class / @click / id land on this button automatically -->
</template>

<!-- multi-root or custom target: bind $attrs yourself -->
<script setup>
defineOptions({ inheritAttrs: false });
</script>
<template>
  <label>{{ label }}</label>
  <input v-bind="$attrs" />
</template>

What it does:Attributes the parent puts on a component that are not declared as props (class, style, id, event listeners) "fall through" to the single root element automatically. With multiple root nodes, or when the wrapper element is not the real target, set `inheritAttrs: false` and bind `v-bind="$attrs"` onto the element you actually want them on (the inner `<input>` of a labelled field, for instance).

app.config.globalProperties

Component
import { createApp } from 'vue';
const app = createApp(App);

// add a property available on every component instance
app.config.globalProperties.$formatDate = (d: Date) =>
  d.toLocaleDateString();

// TS augmentation so $formatDate is typed in templates
declare module 'vue' {
  interface ComponentCustomProperties {
    $formatDate: (d: Date) => string;
  }
}

What it does:globalProperties attaches a value or helper to every component, reachable as `this.$x` in the Options API and `$x` in templates — the Vue 3 replacement for Vue 2 `Vue.prototype.$x`. It is template-only sugar (not available in `<script setup>` script body, where you should just import the helper). Augment `ComponentCustomProperties` for type safety. Use sparingly; explicit imports or provide/inject are usually cleaner.

app.provide — app-level injection

Component
import { createApp } from 'vue';
const app = createApp(App);

// available to EVERY component, no provider component needed
app.provide('apiBase', 'https://api.example.com');

// any component, any depth
<script setup>
import { inject } from 'vue';
const apiBase = inject<string>('apiBase');
</script>

What it does:app.provide registers an injection at the application root, so any component can `inject` it without a wrapping provider component — handy for app-wide config, an HTTP client, or feature flags supplied once at bootstrap. Unlike component-level `provide`, it is not reactive-scoped to a subtree; it is the global default for the whole app. Use a Symbol key for libraries to avoid collisions.

Directive (17)

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

Directive
<div v-if="status === 'loading'">Loading…</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else>Hello {{ user.name }}</div>

<!-- group multiple elements with <template> -->
<template v-if="ready">
  <h1>Title</h1>
  <p>Body</p>
</template>

What it does:Conditional render — when false, the element and all its children are NOT in the DOM. Higher toggle cost than v-show (mount / unmount), but lower idle cost (no DOM at all when hidden). Use `<template v-if>` to wrap multiple siblings without adding a wrapper element.

Options API equivalent
// Options API uses the same template directives

v-show — toggle via display:none

Directive
<div v-show="open">always rendered, just hidden when false</div>

What it does:Always renders the element; toggles visibility by setting `style.display`. Cheaper toggle, more idle DOM. Use v-show when you toggle often and the element is small; use v-if for big subtrees or things that should not even exist when hidden (modals with their own data loading).

v-for — list rendering with :key

Directive
<li v-for="(item, idx) in items" :key="item.id">
  {{ idx + 1 }}. {{ item.name }}
</li>

<!-- iterate object -->
<li v-for="(val, key) in obj" :key="key">{{ key }}: {{ val }}</li>

<!-- range -->
<span v-for="n in 5" :key="n">{{ n }}</span>

What it does:Render an element per item in array / object / Map / Set / integer range. ALWAYS provide a stable `:key` (typically `item.id`) — without it Vue uses index, which causes wrong DOM reuse on insert / remove / reorder. Never use array index as key when the list can reorder.

Options API equivalent
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

v-model — two-way binding on inputs

Directive
<input v-model="text" />
<input type="checkbox" v-model="agreed" />
<input type="checkbox" v-model="selected" value="a" />
<select v-model="picked"><option>x</option><option>y</option></select>

<!-- modifiers -->
<input v-model.lazy="text" />     <!-- sync on change instead of input -->
<input v-model.number="age" />    <!-- cast to number -->
<input v-model.trim="title" />    <!-- trim whitespace -->

What it does:Two-way binding for form inputs. Underneath it expands to `:value` + an `@input` (or `@change` for `.lazy`) listener. Modifiers `.lazy` (sync on blur / change), `.number` (cast to number), `.trim` (trim) save boilerplate. For custom components see defineModel.

v-bind — bind any attribute / prop

Directive
<img v-bind:src="url" :alt="alt" />     <!-- : is shorthand -->

<!-- bind an object — spreads keys -->
<div v-bind="{ id, class: cls, 'data-x': 1 }"></div>

<!-- bind class / style -->
<div :class="['btn', { active: isActive }, sizeCls]"></div>
<div :style="{ color, fontSize: size + 'px' }"></div>

What it does:Bind a JS expression to an HTML attribute or component prop. The colon (`:src`) is the shorthand. Two specialty forms: `v-bind="obj"` spreads all keys (useful for passing `$attrs` through), and `:class` / `:style` accept arrays and objects for conditional combinations.

v-on — event listeners

Directive
<button v-on:click="handle">go</button>
<button @click="handle">go</button>    <!-- @ shorthand -->

<button @click="count++">inline</button>
<button @click="handle($event, item)">with extra arg</button>

<!-- modifiers -->
<form @submit.prevent="save"></form>          <!-- preventDefault -->
<button @click.stop="x"></button>             <!-- stopPropagation -->
<input @keyup.enter="submit" />               <!-- keycode shortcut -->
<input @keyup.ctrl.s.prevent="save" />        <!-- chord + prevent -->
<div @click.self="closeModal"></div>          <!-- only when target === self -->
<div @click.once="trackOnce"></div>           <!-- fire once -->

What it does:Listen for DOM or custom events. `@` is the shorthand. Modifiers cover the 90% of "event boilerplate" you would otherwise write by hand: `.prevent` (preventDefault), `.stop` (stopPropagation), `.self` (only direct target), `.once` (fire once), `.capture` (capture phase), key chords (`.ctrl.s`).

v-html — render raw HTML

Directive
<div v-html="trustedHtml"></div>

<!-- NEVER do this with user input — XSS -->
<div v-html="userBio"></div>   <!-- DANGEROUS -->

What it does:Sets the element's innerHTML to the bound string. Use ONLY with HTML you produced or sanitized yourself — passing untrusted user content opens an XSS hole the same size as innerHTML in plain JS. Run it through DOMPurify or a server-side sanitizer first.

v-text — render as text

Directive
<span v-text="msg"></span>
<!-- equivalent to -->
<span>{{ msg }}</span>

What it does:Sets `textContent`. Equivalent to `{{ msg }}` but as an attribute, so it overrides any child content the element has. Mostly used to keep the template clean when you have a hard-coded skeleton inside `<noscript>` or similar. Mustache interpolation is the idiomatic choice.

v-cloak — hide template until compiled

Directive
<style>[v-cloak] { display: none; }</style>

<div v-cloak>
  {{ message }}
</div>

What it does:Used to hide un-compiled mustache markup during a Flash-Of-Uncompiled-Template (FOUC) when you mount Vue progressively over a server-rendered HTML page. Pair with a CSS rule `[v-cloak] { display: none; }`. Pure-SPA / Vite apps rarely need it.

v-pre — skip compilation

Directive
<span v-pre>{{ this will appear literally }}</span>

What it does:Tells Vue to skip compilation for the element and its children — mustache braces render as literal text. Useful for docs / cheatsheets that need to show un-rendered Vue syntax, or to speed up large trees of pure static content.

v-once — render once, then freeze

Directive
<h1 v-once>{{ title }}</h1>
<!-- title can change later — this element will not update -->

What it does:Renders the element ONCE on first mount; subsequent reactive updates are skipped. Use for content known to be static after first paint (legal copy, server-injected initial values). Helps perf in long lists when one cell is static — wrap with v-once to bypass diff.

Custom directive — vClickOutside

Directive
// directives/clickOutside.ts
import type { Directive } from 'vue';
export const vClickOutside: Directive<HTMLElement, () => void> = {
  mounted(el, binding) {
    el._click = (e: Event) => {
      if (!el.contains(e.target as Node)) binding.value();
    };
    document.addEventListener('click', el._click);
  },
  unmounted(el) { document.removeEventListener('click', el._click); }
};

// in component
<script setup>
import { vClickOutside } from './directives/clickOutside';
</script>
<template>
  <div v-click-outside="close">…</div>
</template>

What it does:Custom directives expose mounted / updated / unmounted / beforeMount hooks for direct DOM access. The kebab-case name in the template (`v-click-outside`) matches the variable `vClickOutside`. Use for low-level DOM concerns; for reusable LOGIC prefer a composable.

v-model on components — argument + modifiers

Directive
<!-- multiple bindings + custom modifier -->
<UserForm
  v-model:first-name="first"
  v-model:last-name.capitalize="last"
/>

<!-- Child reads the modifier (3.4+ defineModel) -->
<script setup lang="ts">
const [last, lastMods] = defineModel<string>('lastName');
function onInput(v: string) {
  last.value = lastMods.capitalize ? v[0].toUpperCase() + v.slice(1) : v;
}
</script>

What it does:On a component, `v-model:name` binds a named model and `.modifier` adds a custom modifier the child can inspect. With defineModel (3.4+), destructure `[model, modifiers]` to read which modifiers the parent applied (e.g. `.capitalize`) and transform the value accordingly. This is how you build inputs that match native v-model ergonomics including `.trim`-style options.

v-memo — skip re-render of a subtree

Directive
<!-- only re-renders this row when item.id or item.active changes -->
<div
  v-for="item in list"
  :key="item.id"
  v-memo="[item.id, item.active]"
>
  <!-- expensive cell content -->
  <HeavyCell :item="item" />
</div>

What it does:v-memo takes a dependency array and skips re-rendering the element and its children unless one of the listed values changes — like React.memo applied at the template level. It is a niche micro-optimization for very large lists (thousands of rows) where most rows are unchanged each update. Misuse (stale deps) causes the UI to not update, so reach for it only after profiling proves the need.

Directive with arg + modifiers

Directive
// v-tooltip:top.delay="text"
import type { Directive } from 'vue';
export const vTooltip: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    const placement = binding.arg ?? 'bottom';   // 'top'
    const delayed = binding.modifiers.delay;      // true
    el.dataset.tip = binding.value;               // the text
    el.dataset.place = placement;
    if (delayed) el.dataset.delay = '500';
  },
  updated(el, binding) { el.dataset.tip = binding.value; },
};

What it does:A custom directive binding carries more than its value: `binding.arg` is the part after the colon (`v-tooltip:top` → `"top"`), `binding.modifiers` is an object of the dotted flags (`.delay` → `{ delay: true }`), and `binding.oldValue` lets `updated` diff against the previous value. Use these to make one directive configurable (placement, delay, variant) without separate directives.

v-bind in <style> — reactive CSS

Directive
<script setup>
import { ref } from 'vue';
const theme = ref('#22d3ee');
const size = ref(16);
</script>
<template><p class="label">colored</p></template>
<style scoped>
.label {
  color: v-bind(theme);
  /* wrap expressions in quotes */
  font-size: v-bind('size + "px"');
}
</style>

What it does:Inside an SFC `<style>` block you can reference reactive state with `v-bind(expr)` — Vue compiles it into a CSS custom property that updates live as the state changes, no inline-style juggling. Wrap any expression (not a bare identifier) in quotes. Great for theme colors, dynamic spacing, or progress widths driven by component state.

v-for over a Map / Set

Directive
<!-- Map: value, key, index -->
<li v-for="(val, key, i) in myMap" :key="key">
  {{ i }}. {{ key }} = {{ val }}
</li>

<!-- Set: value only -->
<li v-for="tag in mySet" :key="tag">{{ tag }}</li>

<script setup>
const myMap = reactive(new Map([['a', 1], ['b', 2]]));
const mySet = reactive(new Set(['vue', 'web']));
</script>

What it does:v-for iterates Map and Set, not just arrays and objects. For a Map the alias order is `(value, key, index)`; for a Set you get just the value. Both stay reactive when wrapped in reactive() — adding or deleting entries updates the list. Maps preserve insertion order, which is why they are handy when render order must match insertion order.

Slot (6)

Default slot — content from parent

Slot
<!-- Card.vue -->
<template>
  <div class="card">
    <slot>fallback when parent provides nothing</slot>
  </div>
</template>

<!-- Parent.vue -->
<Card>
  <p>arbitrary parent content lands here</p>
</Card>

What it does:A `<slot>` element is a placeholder filled by the parent at use site. The text between the `<slot>` open and close tags is the FALLBACK shown when the parent passes nothing. Default slots are the simplest form of component composition — any "layout component" should expose one.

Named slots — multiple insertion points

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

<!-- Parent.vue -->
<Layout>
  <template #header><h1>Title</h1></template>
  <p>main body — goes to the default slot</p>
  <template #footer><small>© 2026</small></template>
</Layout>

What it does:Multiple `<slot name="x">` placeholders, each filled via `<template v-slot:x>` (shorthand `#x`) in the parent. Lets a single component expose several configurable regions — header / footer of a card, actions of a modal. Content without a `<template>` wrapper goes to the default slot.

Scoped slots — child passes data to parent

Slot
<!-- List.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="$index">{{ item.name }}</slot>
    </li>
  </ul>
</template>

<!-- Parent.vue -->
<List :items="users">
  <template #default="{ item, index }">
    <strong>{{ index + 1 }}.</strong> {{ item.name }} ({{ item.email }})
  </template>
</List>

What it does:A slot that exposes child-side data to the parent template. The child writes `<slot :item="item">`, the parent destructures `#default="{ item }"`. This is the foundation of headless / renderless components — child owns logic, parent owns markup. Combine with defineSlots for full TS typing.

Dynamic slot name

Slot
<Layout>
  <template v-slot:[dynamicName]="slotProps">
    content for {{ dynamicName }}
  </template>
</Layout>

<!-- shorthand -->
<Layout>
  <template #[dynamicName]>…</template>
</Layout>

What it does:The slot name itself is a JS expression in brackets. Useful when you iterate over a schema and emit content into matching slots, or when wrapping a child component and forwarding all its named slots dynamically — see the `Object.keys($slots)` pattern.

Conditional slots — $slots check

Slot
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots();
</script>
<template>
  <div class="card">
    <header v-if="slots.header" class="card-head">
      <slot name="header" />
    </header>
    <slot />
    <footer v-if="$slots.footer"><slot name="footer" /></footer>
  </div>
</template>

What it does:Check `$slots.name` (in template) or `useSlots()` (in script) to know whether the parent passed content for a slot, so you can drop the surrounding wrapper element when it would otherwise render empty. The classic case is a card whose header bar should not appear (no border, no padding) unless a header slot was provided.

Renderless component pattern

Slot
<!-- MouseTracker.vue — owns logic, renders nothing of its own -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0), y = ref(0);
const move = (e: MouseEvent) => { x.value = e.clientX; y.value = e.clientY; };
onMounted(() => window.addEventListener('mousemove', move));
onUnmounted(() => window.removeEventListener('mousemove', move));
</script>
<template><slot :x="x" :y="y" /></template>

<!-- Parent owns ALL the markup -->
<MouseTracker v-slot="{ x, y }">
  <p>cursor at {{ x }}, {{ y }}</p>
</MouseTracker>

What it does:A renderless component bundles stateful logic and exposes it purely through a scoped slot, rendering no markup of its own — the parent decides everything visual. It is the slot-based predecessor to composables. In modern Vue a composable usually does the job with less ceremony, but the renderless pattern still shines when the logic must also control where children mount.

Router (13)

vue-router basic setup

Router
// router.ts
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('./pages/Home.vue') },
    { path: '/about', component: () => import('./pages/About.vue') },
    { path: '/:pathMatch(.*)*', component: NotFound }   // 404
  ]
});

// main.ts
createApp(App).use(router).mount('#app');

// App.vue
<template>
  <RouterLink to="/">Home</RouterLink>
  <RouterView />
</template>

What it does:createWebHistory uses the HTML5 History API (clean URLs, server fallback required). createWebHashHistory uses `#/path` (no server config needed). Routes can lazy-load with dynamic import. `:pathMatch(.*)*` is the canonical 404 catch-all. Always mount RouterView somewhere — usually in App.vue.

Dynamic + named routes

Router
const routes = [
  { path: '/user/:id', name: 'user', component: User, props: true },
  { path: '/post/:id(\\d+)', component: Post }   // id must be digits
];

<!-- template -->
<RouterLink :to="{ name: 'user', params: { id: 42 }, query: { tab: 'bio' } }">
  Lei
</RouterLink>

<!-- inside User.vue -->
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
// route.params.id is reactive
</script>

What it does:Dynamic segments (`:id`) become entries on `route.params`. The optional regex (`(\d+)`) constrains the segment. `props: true` injects params as component props for cleaner code. Named routes (`name: "user"`) let you generate URLs by name + params instead of hard-coding paths — refactor-safe.

Nested routes

Router
const routes = [
  {
    path: '/user/:id',
    component: User,
    children: [
      { path: '', component: Overview },          // /user/42
      { path: 'posts', component: Posts },        // /user/42/posts
      { path: 'settings', component: Settings }   // /user/42/settings
    ]
  }
];

// User.vue
<template>
  <h1>{{ user.name }}</h1>
  <RouterView />     <!-- child route renders here -->
</template>

What it does:Children render inside the parent's `<RouterView>`. Empty-path child (`path: ""`) is the default rendered when only the parent path matches. Common pattern for tabs / sub-sections that share a layout (user profile with posts / settings tabs). The URL composes naturally: `/user/42/posts`.

Navigation guards — beforeEach

Router
router.beforeEach(async (to, from) => {
  const auth = useAuthStore();
  if (to.meta.requiresAuth && !auth.user) {
    return { name: 'login', query: { redirect: to.fullPath } };
  }
  // return true / undefined to allow
});

// per-route
const routes = [
  { path: '/admin', component: Admin, meta: { requiresAuth: true },
    beforeEnter: (to) => { /* … */ } }
];

// in-component
<script setup>
import { onBeforeRouteLeave } from 'vue-router';
onBeforeRouteLeave((to, from) => {
  if (dirty.value && !confirm('Discard changes?')) return false;
});
</script>

What it does:Hooks that intercept navigation. Return `true` / undefined to allow, `false` to cancel, or a route location object to redirect. Three layers: global (`router.beforeEach`), per-route (`beforeEnter`), and per-component (`onBeforeRouteLeave` / `onBeforeRouteUpdate`). Global guards run for every transition — keep them cheap.

Programmatic navigation

Router
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();

router.push('/about');
router.push({ name: 'user', params: { id: 42 } });
router.replace('/login');         // no history entry
router.go(-1);                    // browser back
router.back();                    // alias for go(-1)
router.forward();
</script>

What it does:useRouter inside `<script setup>` returns the router instance for programmatic navigation. push adds a history entry; replace does not (use it for redirects you do not want in back history); go(n) navigates n steps in either direction. All push / replace methods return a promise — await for navigation completion.

useRoute — read current route reactively

Router
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { watch } from 'vue';
const route = useRoute();

// reactive — re-runs whenever params change
watch(() => route.params.id, (id) => loadUser(id), { immediate: true });

// access query / hash / meta
console.log(route.query.tab, route.hash, route.meta.requiresAuth);
</script>

What it does:useRoute returns a reactive object describing the current route — `params`, `query`, `hash`, `path`, `fullPath`, `meta`, `name`. Watch a specific field to re-fetch when the URL changes (e.g. switching `/user/1` → `/user/2` while staying on the same component). Do NOT destructure — that breaks reactivity.

Lazy-loaded route components

Router
const routes = [
  // each route becomes its own chunk
  { path: '/', component: () => import('./pages/Home.vue') },
  { path: '/dashboard', component: () =>
      import(/* webpackChunkName: "admin" */ './pages/Dashboard.vue') }
];

// with Suspense for async setup
<template>
  <Suspense>
    <RouterView />
    <template #fallback><Spinner /></template>
  </Suspense>
</template>

What it does:Passing `() => import(...)` instead of a directly imported component triggers code-splitting — each route ships as a separate chunk loaded on first visit. Optionally add a `webpackChunkName` magic comment to group several routes into one chunk. Wrap RouterView in <Suspense> for the loading state.

RouterLink active classes

Router
<!-- exact match gets router-link-exact-active too -->
<RouterLink to="/users">Users</RouterLink>

<!-- custom active classes -->
<RouterLink to="/users" active-class="on" exact-active-class="on-exact">
  Users
</RouterLink>

<!-- full control with v-slot -->
<RouterLink to="/users" custom v-slot="{ href, navigate, isActive }">
  <a :href="href" :class="{ active: isActive }" @click="navigate">Users</a>
</RouterLink>

What it does:RouterLink adds `router-link-active` to links whose target is a prefix of the current route and `router-link-exact-active` for an exact match — override the names with `active-class` / `exact-active-class`. For total control pass `custom` plus a `v-slot`, which hands you `href`, `navigate`, `isActive`, and `isExactActive` to render your own element (a styled button, a list item) while keeping correct SPA navigation.

Route meta + typed fields

Router
// augment the type so route.meta is typed everywhere
import 'vue-router';
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean;
    title?: string;
    roles?: string[];
  }
}

const routes = [
  { path: '/admin', component: Admin,
    meta: { requiresAuth: true, roles: ['admin'], title: 'Admin' } },
];

// in a guard — fully typed
router.beforeEach((to) => {
  document.title = to.meta.title ?? 'App';
});

What it does:Every route carries an arbitrary `meta` object — the standard place to attach auth flags, page titles, layout names, and breadcrumb data that guards and layouts read. Augment the `RouteMeta` interface via `declare module "vue-router"` so `to.meta.xxx` is fully typed across guards and components instead of `any`. Nested routes inherit and merge their parents' meta.

scrollBehavior — restore scroll on navigation

Router
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // back/forward → restore previous position
    if (savedPosition) return savedPosition;
    // anchor link → scroll to the element
    if (to.hash) return { el: to.hash, behavior: 'smooth' };
    // otherwise → top of page
    return { top: 0 };
  },
});

What it does:scrollBehavior runs on every navigation and returns where the page should scroll. `savedPosition` is non-null only on browser back/forward, so returning it gives native "restore where I was" behavior; `to.hash` lets you scroll to an anchor; the default `{ top: 0 }` resets to the top for fresh navigations. Return a promise to defer scrolling until async content has loaded.

onBeforeRouteUpdate — same component, new params

Router
<script setup lang="ts">
import { onBeforeRouteUpdate } from 'vue-router';
import { ref } from 'vue';
const user = ref<User | null>(null);

// fires when navigating /user/1 → /user/2 (component is REUSED)
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    user.value = await fetchUser(to.params.id as string);
  }
});
</script>

What it does:When you navigate between two routes that resolve to the SAME component (only params differ, e.g. `/user/1` → `/user/2`), Vue reuses the instance and onMounted does NOT run again. onBeforeRouteUpdate is the hook that fires on that transition — use it (or `watch(() => route.params.id)`) to refetch data. Forgetting this is the classic "page does not update when I click the next item" bug.

Router lazy load with error handling

Router
import { defineAsyncComponent } from 'vue';

const routes = [
  {
    path: '/reports',
    component: defineAsyncComponent({
      loader: () => import('./pages/Reports.vue'),
      loadingComponent: PageSpinner,
      errorComponent: ChunkLoadError,   // shown if the chunk fails
      timeout: 8000,
    }),
  },
];

// global recovery: reload once when a stale chunk 404s after deploy
router.onError((err) => {
  if (err.message.includes('Failed to fetch dynamically imported module')) {
    window.location.reload();
  }
});

What it does:Lazy route chunks can fail to load — typically when a new deploy invalidates the old chunk hash while a user still has the previous page open. Wrap the loader in `defineAsyncComponent` with an `errorComponent` for a graceful in-page fallback, and add a global `router.onError` that detects the "Failed to fetch dynamically imported module" message and does a one-time reload to pull fresh chunks.

isNavigationFailure — detect aborted navigation

Router
import { isNavigationFailure, NavigationFailureType } from 'vue-router';

const failure = await router.push('/checkout');
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
  // a guard returned false — navigation was cancelled
  toast('Please complete the form first');
}
// other types: duplicated (same route), cancelled (newer nav started)

What it does:router.push / replace resolve to a Navigation Failure object (rather than throwing) when a guard cancels or redirects the navigation. `isNavigationFailure(result, type)` lets you distinguish an aborted navigation (a guard returned false), a duplicated one (pushed the current route again), or a cancelled one (a newer navigation superseded it) — so you can react correctly instead of assuming every push succeeded.

Pinia / State (10)

Pinia — install and use

Pinia / State
// main.ts
import { createPinia } from 'pinia';
createApp(App).use(createPinia()).mount('#app');

// stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (s) => s.count * 2 },
  actions: { inc() { this.count++; } }
});

// in component
<script setup>
import { useCounterStore } from '@/stores/counter';
const counter = useCounterStore();
counter.inc();
</script>

What it does:Pinia is the official Vue 3 store, replacing Vuex. Each store is a hook (`useXxxStore`) you call inside `<script setup>`. The first arg is a unique id (also the DevTools label). Stores are lazily instantiated on first use and shared across the app — no boilerplate, full TS inference, hot-module reload friendly.

defineStore — options syntax

Pinia / State
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({ name: '', email: '', loading: false }),
  getters: {
    initials: (s) => s.name.split(' ').map(w => w[0]).join(''),
  },
  actions: {
    async load(id: number) {
      this.loading = true;
      const r = await fetch(`/api/user/${id}`).then(r => r.json());
      this.name = r.name; this.email = r.email;
      this.loading = false;
    }
  }
});

What it does:Options form mirrors Vuex shape: state (factory), getters (Vue computed), actions (Vue methods). Inside actions `this` is the store — TypeScript infers it correctly. State factory MUST return a fresh object per call (otherwise SSR shares state across requests).

defineStore — setup syntax (recommended)

Pinia / State
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCartStore = defineStore('cart', () => {
  // state
  const items = ref<Item[]>([]);
  // getters
  const total = computed(() => items.value.reduce((s, i) => s + i.price, 0));
  // actions
  function add(i: Item) { items.value.push(i); }
  function clear() { items.value = []; }

  return { items, total, add, clear };
});

What it does:Setup-store form: pass a setup function returning whatever you want exposed. Inside you use raw refs / computed / functions — same shape as `<script setup>`. Better for composition (extract logic into a composable and call it from the store), better for TS inference, and the only way to use `watch` / lifecycle inside the store.

Pinia actions — async + this

Pinia / State
// options form
actions: {
  async fetchPosts() {
    this.loading = true;
    try {
      this.posts = await api.posts();
    } finally {
      this.loading = false;
    }
  }
}

// setup form — call from component
const cart = useCartStore();
await cart.fetchPosts();

// $patch — batch state changes
cart.$patch({ items: [], total: 0 });
cart.$patch((s) => { s.items.push(x); s.discount = 10; });

What it does:Actions can be async — no commit / dispatch indirection. `this` is fully typed in options form. `$patch` batches multiple mutations into ONE reactive update (useful when changing several fields together so subscribers only see the final state). Subscribe to state changes with `store.$subscribe`.

Pinia getters — like computed

Pinia / State
getters: {
  // simple
  double: (s) => s.count * 2,
  // access this for other getters
  doublePlusOne(): number { return this.double + 1; },
  // parameterized getter — returns a function
  getById: (s) => (id: number) => s.users.find(u => u.id === id),
  // use another store
  totalWithTax(): number {
    const tax = useTaxStore();
    return this.total * (1 + tax.rate);
  }
}

What it does:Getters are Vue computed properties cached by their reactive dependencies — they only recompute when underlying state changes. Use a function-returning getter (`getById`) for parameterized lookups (not memoized — call multiple times = multiple work). Cross-store reads: call another `useXxxStore()` inside the getter.

storeToRefs — destructure without losing reactivity

Pinia / State
<script setup>
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
// WRONG — name, email become plain values, lose reactivity
const { name, email } = userStore;

// RIGHT — refs that stay reactive
const { name, email } = storeToRefs(userStore);
// actions are still destructured directly (they don't need to be refs)
const { login, logout } = userStore;
</script>

What it does:Pinia stores are reactive Proxies, so destructuring values breaks reactivity (same trap as plain `reactive`). storeToRefs returns refs that point INTO the store — destructure those for state and getters. Actions are plain functions and can be destructured directly.

Pinia outside setup — pass pinia

Pinia / State
// utils/api.ts — NOT a component
import { useUserStore } from '@/stores/user';
import { pinia } from '@/main';   // exported from main.ts

export async function getCurrentUser() {
  // Pass pinia explicitly outside setup
  const store = useUserStore(pinia);
  return store.user;
}

// router guard
router.beforeEach((to) => {
  const auth = useAuthStore();    // works — app is mounted before guards run
  if (!auth.user) return '/login';
});

What it does:Inside `<script setup>`, route guards (after mount), and other places where the active Pinia instance is set, `useXxxStore()` Just Works. In standalone utility files imported by tests / SSR setup / pre-mount code, pass the pinia instance explicitly: `useXxxStore(pinia)`. Forgetting this is the "getActivePinia was called with no active Pinia" error.

Pinia $reset — restore initial state

Pinia / State
// options store — $reset works out of the box
const store = useCounterStore();
store.$reset();   // state back to the factory's initial values

// setup store — $reset is NOT auto-available, define it yourself
export const useCart = defineStore('cart', () => {
  const items = ref<Item[]>([]);
  function $reset() { items.value = []; }
  return { items, $reset };
});

What it does:In an options-syntax store, `store.$reset()` re-runs the state factory and replaces state with the initial values — handy on logout or form cancel. Setup-syntax stores do NOT get `$reset` automatically (Pinia cannot know the initial shape), so you define your own reset function that clears the refs. Mind this gap when migrating an options store to setup syntax.

Pinia $subscribe + $onAction

Pinia / State
const store = useCartStore();

// react to ANY state change
store.$subscribe((mutation, state) => {
  localStorage.setItem('cart', JSON.stringify(state));
}, { detached: true });   // survive component unmount

// observe action calls (logging, analytics, error capture)
store.$onAction(({ name, args, after, onError }) => {
  console.log('calling', name, args);
  after((result) => console.log('done', name, result));
  onError((err) => console.error(name, 'failed', err));
});

What it does:`$subscribe` runs a callback after every state mutation — the canonical place to persist a store to localStorage. Pass `{ detached: true }` so the subscription is not torn down when the registering component unmounts. `$onAction` taps into action invocations, exposing `after` and `onError` callbacks; use it for cross-cutting logging, analytics, or centralized error capture without touching each action.

Cross-store composition

Pinia / State
export const useCheckoutStore = defineStore('checkout', () => {
  // call another store INSIDE the setup function
  const cart = useCartStore();
  const user = useUserStore();

  const canCheckout = computed(
    () => cart.items.length > 0 && !!user.address
  );
  async function submit() {
    await api.order(cart.items, user.id);
    cart.clear();
  }
  return { canCheckout, submit };
});

What it does:A store can use other stores: call `useOtherStore()` inside the setup function (or inside an action/getter for options stores) and read its state or call its actions. Pinia resolves the dependency lazily at first use, so circular references between two stores are fine as long as you do not read each other at module top level. This keeps each store focused while composing them at a higher layer.

Composable (15)

Custom composable — useToggle

Composable
// composables/useToggle.ts
import { ref } from 'vue';

export function useToggle(initial = false) {
  const value = ref(initial);
  const toggle = () => { value.value = !value.value; };
  return { value, toggle };
}

// in component
<script setup>
import { useToggle } from '@/composables/useToggle';
const { value: open, toggle: toggleOpen } = useToggle();
</script>

What it does:A "composable" is a function whose name starts with `use` that wraps stateful logic into a reusable hook. The Vue analog to React custom hooks. Composables can call other composables, use lifecycle hooks (only inside setup), and return refs / functions. The "use" name prefix is convention, not enforced.

useMouse — track cursor

Composable
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0), y = ref(0);
  const update = (e: MouseEvent) => { x.value = e.pageX; y.value = e.pageY; };
  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));
  return { x, y };
}

// in component
const { x, y } = useMouse();

What it does:Canonical composable showing the lifecycle pattern: subscribe on mount, unsubscribe on unmount, expose reactive state. The same shape works for resize, scroll, online/offline, intersection observer. Note the cleanup is colocated with the setup — losing the unsubscribe is the most common composable bug.

useLocalStorage — sync with localStorage

Composable
import { ref, watch } from 'vue';

export function useLocalStorage<T>(key: string, initial: T) {
  const stored = typeof window !== 'undefined'
    ? window.localStorage.getItem(key) : null;
  const value = ref<T>(stored ? JSON.parse(stored) : initial);

  watch(value, (v) => {
    if (typeof window !== 'undefined') {
      window.localStorage.setItem(key, JSON.stringify(v));
    }
  }, { deep: true });

  return value;
}

// component
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light');

What it does:Returns a ref that mirrors a localStorage key — read it like any ref, write it and the change is persisted. The `typeof window` guard is mandatory for SSR (Nuxt / Astro) — without it the build crashes on the server pass. Pair with watch + `{ deep: true }` if you store object state.

useFetch — minimal data fetching

Composable
import { ref, watchEffect } from 'vue';

export function useFetch<T>(url: () => string) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  watchEffect((onCleanup) => {
    const ctrl = new AbortController();
    onCleanup(() => ctrl.abort());
    loading.value = true;
    fetch(url(), { signal: ctrl.signal })
      .then(r => r.json()).then(d => { data.value = d; loading.value = false; })
      .catch(e => { if (e.name !== 'AbortError') { error.value = e; loading.value = false; } });
  });
  return { data, error, loading };
}

// caller passes a GETTER so watchEffect tracks it
const id = ref(1);
const { data } = useFetch<User>(() => `/api/user/${id.value}`);

What it does:Prototype-grade fetch composable. Crucially, accepts a getter `() => string` (not a string) so watchEffect can track the dependency and re-fetch when it changes. onCleanup aborts the previous request so the LATEST URL always wins. Production code should reach for VueUse `useFetch` or a real data library (TanStack Query Vue).

VueUse — useEventListener

Composable
import { useEventListener } from '@vueuse/core';

// auto-cleans on unmount, no boilerplate
useEventListener(window, 'resize', () => { /* … */ });
useEventListener(document, 'keydown', onKey);

// target can also be a ref
const elRef = ref<HTMLElement | null>(null);
useEventListener(elRef, 'click', onClick);

What it does:@vueuse/core is the de-facto "lodash for composables" — 200+ pre-built composables. useEventListener removes the addEventListener / removeEventListener boilerplate and accepts a ref as the target (it re-attaches when the ref changes). Install: `pnpm add @vueuse/core`.

VueUse — useDebouncedRef / useThrottleFn

Composable
import { refDebounced, useThrottleFn } from '@vueuse/core';
import { ref } from 'vue';

const search = ref('');
const debounced = refDebounced(search, 300);  // updates 300ms after stable

watch(debounced, (q) => fetchResults(q));

// throttled function
const onScroll = useThrottleFn(() => updateScrollPos(), 100);
addEventListener('scroll', onScroll);

What it does:refDebounced returns a ref that follows another with a quiet-time delay — every change to source resets the timer. useThrottleFn wraps a function so it fires at most once per interval (leading edge). 300 ms for search debounce, 100 ms for scroll throttle are common sweet spots.

provide / inject — dependency injection

Composable
// parent
<script setup lang="ts">
import { provide, ref, readonly } from 'vue';
const theme = ref<'light' | 'dark'>('light');
provide('theme', readonly(theme));   // child can read but not write
provide('toggleTheme', () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; });
</script>

// child (any descendant depth)
<script setup lang="ts">
import { inject } from 'vue';
const theme = inject<Ref<'light' | 'dark'>>('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>

What it does:provide passes a value to any descendant via inject — skips prop drilling. Wrap mutable refs in readonly to prevent child writes; expose explicit setters for the parts you allow children to change. Use Symbol keys in libraries to avoid name collisions. For app-wide global state Pinia is usually cleaner.

useTemplateRef — typed template ref (3.5+)

Composable
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';

const inputRef = useTemplateRef<HTMLInputElement>('myInput');

onMounted(() => inputRef.value?.focus());
</script>
<template>
  <input ref="myInput" />
</template>

What it does:Vue 3.5+ replacement for the `const x = ref<HTMLInputElement | null>(null)` + `<input ref="x">` pattern. The string passed to useTemplateRef matches the `ref="…"` attribute, and the returned ref is typed. The old `ref(null)` form still works — useTemplateRef is just clearer about intent.

VueUse — useStorage

Composable
import { useStorage } from '@vueuse/core';

// reactive ref synced to localStorage (type inferred from default)
const settings = useStorage('app-settings', { theme: 'dark', fontSize: 16 });
settings.value.theme = 'light';   // persisted automatically

// sessionStorage instead
const draft = useStorage('draft', '', sessionStorage);

What it does:VueUse `useStorage` returns a ref that two-way syncs with localStorage (or sessionStorage). It infers the serializer from the default value, handles JSON for objects, and even syncs across browser tabs via the `storage` event. Compared to a hand-rolled composable it adds SSR safety, cross-tab sync, and configurable serializers — install with `pnpm add @vueuse/core`.

VueUse — useIntersectionObserver

Composable
import { ref } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';

const target = ref<HTMLElement | null>(null);
const isVisible = ref(false);

useIntersectionObserver(target, ([entry]) => {
  isVisible.value = entry?.isIntersecting ?? false;
  // e.g. trigger lazy load / infinite scroll when entry is visible
}, { threshold: 0.1 });

What it does:Wraps the IntersectionObserver API into a composable that observes a template ref and auto-disconnects on unmount. The standard tool for lazy-loading images, infinite scroll (observe a sentinel at the list end), reveal-on-scroll animations, and viewport analytics — without manually creating and tearing down the observer. `threshold` controls how much of the element must be visible to fire.

VueUse — useClipboard

Composable
import { useClipboard } from '@vueuse/core';

const { copy, copied, isSupported } = useClipboard();

// copied is a ref that flips true for ~1.5s after a successful copy
async function onCopy() {
  await copy('text to copy');
}
// in template:  <button @click="onCopy">{{ copied ? 'Copied!' : 'Copy' }}</button>

What it does:useClipboard wraps the async Clipboard API with a reactive `copied` flag that auto-resets after a timeout — perfect for "Copy / Copied!" button feedback without manual setTimeout bookkeeping. `isSupported` lets you hide the button on browsers without clipboard access, and you can pass a `source` ref to copy reactively.

inject with default + Symbol key

Composable
// keys.ts — typed injection key
import type { InjectionKey, Ref } from 'vue';
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> =
  Symbol('theme');

// provider
provide(ThemeKey, theme);   // type-checked value

// consumer — default value if no provider above
const theme = inject(ThemeKey, ref('light'));
// factory default for expensive values
const cfg = inject(ConfigKey, () => makeConfig(), true);

What it does:Use an `InjectionKey<T>` Symbol instead of a string key to get full type inference on both `provide` and `inject` and to avoid name collisions across a large app or library. `inject(key, default)` supplies a fallback when no ancestor provided the key; pass a factory plus `true` as the third arg when the default is expensive to build so it is only created on demand.

Composable returning reactive state

Composable
import { reactive, toRefs } from 'vue';

export function useCounter(start = 0) {
  const state = reactive({ count: start, doubled: 0 });
  const inc = () => { state.count++; state.doubled = state.count * 2; };
  // return toRefs so the caller can destructure AND stay reactive
  return { ...toRefs(state), inc };
}

// caller
const { count, doubled, inc } = useCounter(10);

What it does:A composable that holds its state in a `reactive` object should return `toRefs(state)` (spread alongside its methods) so the caller can destructure each field and keep reactivity. Returning the reactive object directly would break the moment the caller destructures it. This is the idiomatic shape: refs out for state, plain functions out for actions.

VueUse — useMediaQuery / breakpoints

Composable
import { useMediaQuery, useBreakpoints, breakpointsTailwind } from '@vueuse/core';

// reactive boolean for any media query
const isDark = useMediaQuery('(prefers-color-scheme: dark)');
const isWide = useMediaQuery('(min-width: 1024px)');

// named breakpoints
const bp = useBreakpoints(breakpointsTailwind);
const isDesktop = bp.greaterOrEqual('lg');

What it does:useMediaQuery returns a reactive boolean that tracks a CSS media query — perfect for responsive logic in JS (render a different component on mobile, react to `prefers-reduced-motion` or `prefers-color-scheme`). useBreakpoints builds on it with named breakpoint sets (Tailwind, Bootstrap, or your own) and helpers like `greaterOrEqual("lg")`, keeping JS breakpoints in sync with your CSS without a resize-listener of your own.

VueUse — useAsyncState

Composable
import { useAsyncState } from '@vueuse/core';

const { state, isLoading, isReady, error, execute } = useAsyncState(
  (id: number) => fetch(`/api/user/${id}`).then(r => r.json()),
  null,                 // initial state while loading
  { immediate: true },  // run on mount
);

// re-run with new args
function reload(id: number) { execute(0, id); }

What it does:useAsyncState wraps an async function into reactive `state`, `isLoading`, `isReady`, and `error` refs plus an `execute` to re-run it — the quick way to drive a "loading / loaded / error" UI without hand-writing the boilerplate three times per screen. It supports an initial value, immediate-vs-lazy execution, and a `resetOnExecute` option. For caching, deduping, and revalidation across components, reach for TanStack Query Vue instead.

Pitfall (19)

Pitfall: ref vs reactive — which one?

Pitfall
// RULE OF THUMB
const count = ref(0);                    // primitives → ref
const user = ref({ name: 'Lei' });       // also fine for objects you may replace whole

const cart = reactive({ items: [], total: 0 });   // object you mutate in place

// REPLACEMENT pattern — ref wins
const items = ref<Item[]>([]);
items.value = await fetch(/*…*/);   // wholesale replace, easy

// MUTATION pattern — reactive wins (no .value noise)
const form = reactive({ name: '', age: 0 });
form.name = 'Lei';                  // looks like a plain object

What it does:Practical rule: use `ref` for primitives and for objects you might REPLACE wholesale (API responses, route data). Use `reactive` for objects you MUTATE in place (form state, settings panel). The other rule: `ref` ALWAYS works; `reactive` only works on objects. When in doubt, ref is the safer default.

Pitfall: destructuring reactive loses reactivity

Pitfall
import { reactive, toRefs } from 'vue';
const state = reactive({ x: 1, y: 2 });

// WRONG — x, y become plain numbers
const { x, y } = state;
// x is 1 FOREVER no matter what state.x becomes

// RIGHT — toRefs hands out refs that POINT into state
const { x, y } = toRefs(state);
// x.value tracks state.x both ways

// also: spreading loses reactivity
const copy = { ...state };  // plain object

What it does:The #1 Vue 3 gotcha. Reactivity lives on the Proxy — destructuring or spreading copies values OUT of the proxy, so they no longer track. Fix with toRefs (destructure into refs that point back). Same trap with Pinia stores — use storeToRefs.

Pitfall: v-for + v-if on the same element

Pitfall
<!-- WRONG — v-if has HIGHER priority than v-for in Vue 3 -->
<!-- (the reverse of Vue 2!) — `item` is not in scope yet -->
<li v-for="item in items" v-if="item.show">{{ item.name }}</li>

<!-- RIGHT — filter in the source, OR wrap v-for in <template> -->
<li v-for="item in visibleItems" :key="item.id">{{ item.name }}</li>

<!-- or -->
<template v-for="item in items" :key="item.id">
  <li v-if="item.show">{{ item.name }}</li>
</template>

<script setup>
const visibleItems = computed(() => items.value.filter(i => i.show));
</script>

What it does:In Vue 3 the precedence FLIPPED from Vue 2 — `v-if` now wins. That means `item` is not yet in scope when `v-if` evaluates, so this combo is a hard error (Vue logs a warning). Solve by filtering in computed (preferred, also faster) or splitting into a `<template v-for>` + `<li v-if>` pair.

Pitfall: forgetting :key in v-for

Pitfall
<!-- WRONG — Vue falls back to in-place patch (sometimes called "in-place reuse") -->
<li v-for="item in items">{{ item.name }}</li>
<!-- insert / remove / reorder → DOM gets reused for the WRONG item -->

<!-- RIGHT — stable id -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

<!-- AVOID — index as key when list can reorder -->
<li v-for="(item, idx) in items" :key="idx">{{ item.name }}</li>

What it does:Without `:key` Vue does "in-place patch" — it reuses existing DOM and just rewrites text content. Visible bugs: focus on the wrong input, wrong checkbox stays checked, transitions play on the wrong row. Always provide a STABLE unique key (typically `item.id`). Index works only for never-reordered, never-inserted lists.

Pitfall: mutating props

Pitfall
// WRONG — Vue warns "Avoid mutating a prop directly"
const props = defineProps<{ user: User }>();
props.user.name = 'new';   // also mutates parent state via the same reference

// RIGHT for primitives — use v-model / emit
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [string] }>();
emit('update:modelValue', 'new');

// or with defineModel (3.4+)
const model = defineModel<string>();
model.value = 'new';

// RIGHT for derived state — make a local copy
const localUser = ref({ ...props.user });

What it does:Props are a one-way contract: parent owns, child reads. Vue warns when you write to one. For two-way binding use v-model + defineModel (3.4+) or the manual prop + emit pair. For derived child state, copy into a local ref. Never mutate `props.user.name` directly — you mutate the parent through the shared reference.

Pitfall: watching reactive vs ref

Pitfall
import { ref, reactive, watch } from 'vue';

const r = ref(0);
const obj = reactive({ x: 0 });

// REF — pass it directly
watch(r, (n) => console.log(n));

// REACTIVE OBJECT — pass it directly to watch the WHOLE object (deep auto)
watch(obj, (n) => console.log(n));

// REACTIVE PROPERTY — must use a GETTER (passing obj.x passes the number 0!)
watch(() => obj.x, (n) => console.log(n));

// MULTIPLE sources
watch([r, () => obj.x], ([rv, xv]) => console.log(rv, xv));

What it does:watch on a ref takes the ref directly. watch on a reactive OBJECT takes the object (deep is implied). But watching a single PROPERTY of a reactive object requires a getter — `watch(obj.x, …)` passes the unwrapped value at that moment, not a tracked source. This is the single most common "my watcher does not fire" bug.

Pitfall: deep: true is expensive

Pitfall
// EXPENSIVE — walks every property every change
watch(bigObj, (v) => save(v), { deep: true });

// CHEAPER — watch only the field you care about
watch(() => bigObj.draft.title, (t) => saveTitle(t));

// or memoize a shape via computed
const summary = computed(() => ({ id: bigObj.id, name: bigObj.name }));
watch(summary, (s) => save(s));

What it does:`{ deep: true }` walks the entire object tree on every change to detect what is different. On a 10k-entry array or deep schema state, this can dominate frame time. Prefer to watch a specific getter, or distill the watched shape via computed. Pinia stores are deeply reactive by default — watch precise getters, not the whole store.

Pitfall: async setup needs <Suspense>

Pitfall
<!-- Child.vue -->
<script setup lang="ts">
// top-level await — makes setup async
const user = await fetch('/api/me').then(r => r.json());
</script>

<!-- Parent — REQUIRED, otherwise child stays unmounted forever -->
<template>
  <Suspense>
    <Child />
    <template #fallback>Loading…</template>
  </Suspense>
</template>

What it does:Top-level await in `<script setup>` makes the component's setup async. Without a `<Suspense>` boundary higher up, the component never renders and you see a blank — no error, just nothing. Wrap any async-setup component in <Suspense> at the parent (or use vue-router's built-in support).

Pitfall: this in <script setup>

Pitfall
<script setup>
// WRONG — this is undefined in setup
function handleClick() {
  this.count++;          // TypeError
}

// RIGHT — refer to refs directly
const count = ref(0);
function handleClick() {
  count.value++;
}
</script>

What it does:In `<script setup>` (and inside `setup()` of options form) there is NO `this`. State is plain variables. If you copy-paste Options API code, every `this.xxx` becomes `xxx.value` (for ref) or just `xxx` (for reactive). The mental model is "module-scoped variables", not "component instance".

Pitfall: defineProps default for arrays / objects

Pitfall
// WRONG — every instance shares the SAME array
withDefaults(defineProps<{ tags?: string[] }>(), {
  tags: []           // ERROR (runtime form would warn)
});

// RIGHT — default must be a FUNCTION returning a fresh value
withDefaults(defineProps<{ tags?: string[]; user?: User }>(), {
  tags: () => [],
  user: () => ({ name: '' })
});

// 3.5+: destructuring with default avoids the function-factory dance
const { tags = [], user = { name: '' } } = defineProps<{ tags?: string[]; user?: User }>();

What it does:Arrays / objects as default values MUST come from a factory function — otherwise every component instance shares the same reference and mutating one mutates all. This is the same trap as React `useState(initialObject)` with a non-stable default. Vue 3.5+ destructuring-with-default form sidesteps the dance.

Pitfall: Composables outside setup

Pitfall
// WRONG — onMounted has no active component instance to attach to
function init() {
  onMounted(() => console.log('mounted'));   // warning + no-op
}
init();   // called too early, outside setup

// RIGHT — call composables INSIDE <script setup> body
<script setup>
import { useMouse } from '@/composables/useMouse';
const { x, y } = useMouse();    // OK — synchronously inside setup
</script>

// Also wrong — calling a composable inside a click handler
function onClick() {
  const { x } = useMouse();    // warning, no lifecycle hook can attach
}

What it does:Composables that use lifecycle hooks (onMounted etc.) or inject must be called SYNCHRONOUSLY inside `<script setup>` or `setup()` — same rule as React hooks. Not in async callbacks, not in event handlers, not at top-level of a non-setup file. Vue logs a warning when the rule breaks.

Pitfall: SSR hydration mismatch

Pitfall
<!-- WRONG — server renders 0, client immediately renders 1234 → mismatch -->
<script setup>
const time = ref(Date.now());
</script>
<template>{{ time }}</template>

<!-- RIGHT — render placeholder on server, real value AFTER mount -->
<script setup>
import { ref, onMounted } from 'vue';
const time = ref<number | null>(null);
onMounted(() => { time.value = Date.now(); });
</script>
<template>{{ time ?? '—' }}</template>

<!-- or use <ClientOnly> in Nuxt -->
<ClientOnly><InteractiveWidget /></ClientOnly>

What it does:Hydration mismatch happens when SSR HTML and client first render produce different content — Date.now(), Math.random(), localStorage, window.matchMedia all differ. Vue logs "Hydration node mismatch" and re-renders, breaking SSR benefits. Fix: render a stable placeholder server-side, fill the dynamic value in onMounted, or wrap in `<ClientOnly>` (Nuxt) / equivalent guard.

Pitfall: ref array index assignment

Pitfall
import { ref } from 'vue';
const list = ref([1, 2, 3]);

// FINE in Vue 3 — index assignment IS tracked (unlike Vue 2)
list.value[0] = 99;          // triggers update
list.value.length = 1;       // also tracked

// but forgetting .value silently does nothing
list[0] = 99;                // WRONG — mutates a plain array property, no update

What it does:A Vue 2 reflex is to reach for `Vue.set` / `this.$set` to make index or length assignments reactive. In Vue 3 the Proxy-based system tracks `arr[i] = x` and `arr.length = n` natively, so `$set` is gone and unnecessary. The real Vue 3 trap is forgetting `.value` on a ref — `list[0] = 99` writes onto the ref OBJECT, not the array, and nothing updates.

Pitfall: losing reactivity through a closure

Pitfall
import { ref, watch } from 'vue';
const count = ref(0);

// WRONG — captures the value 0 once, never sees updates
const snapshot = count.value;
watch(() => snapshot, () => {});   // never fires

// RIGHT — pass the ref or a getter so the dependency is tracked
watch(count, (n) => console.log(n));
watch(() => count.value, (n) => console.log(n));

What it does:Reading `ref.value` (or a reactive property) into a plain local variable captures a one-time snapshot — the variable does not track future changes. Anything that needs to react must receive the ref itself or a getter (`() => ref.value`), not the unwrapped value. This is the same root cause as the destructuring pitfall, just hidden inside a function body.

Pitfall: emit name casing

Pitfall
// Child — emit a camelCase event
const emit = defineEmits<{ updateValue: [string] }>();
emit('updateValue', 'x');

<!-- Parent — listen with kebab-case in the template -->
<Child @update-value="onUpdate" />
<!-- @updateValue also works in <script>, but kebab is the template norm -->

What it does:Vue auto-converts between camelCase event names declared in `defineEmits` and the kebab-case form used in HTML templates: emit `updateValue`, listen with `@update-value`. HTML attributes are case-insensitive, so a camelCase listener in the template can be silently lowercased by the browser and never match. Declare events camelCase, listen kebab-case in templates, and the mapping just works.

Pitfall: reactivity lost across await

Pitfall
<script setup>
import { onMounted } from 'vue';

async function load() {
  const data = await fetch('/api').then(r => r.json());
  // WRONG — registering a lifecycle hook AFTER await
  onMounted(() => {});   // warning: no active instance
}
load();

// RIGHT — register hooks synchronously, do async work inside them
onMounted(async () => {
  const data = await fetch('/api').then(r => r.json());
});
</script>

What it does:Lifecycle hooks (onMounted, onUnmounted) and inject must be called synchronously during setup — Vue tracks the "current instance" only until the first await. Code after an `await` runs on a later microtask with no active instance, so registering a hook there warns and no-ops. Register hooks first, then do the async work inside the hook callback.

Pitfall: v-html and scoped styles

Pitfall
<template>
  <!-- scoped CSS does NOT reach v-html'd content -->
  <div v-html="article" class="prose"></div>
</template>
<style scoped>
/* .prose h2 { … }  ← will NOT apply to injected markup */
</style>
<style>
/* use a non-scoped block, or :deep() */
.prose :deep(h2) { color: #22d3ee; }
</style>

What it does:Scoped styles work by stamping a data-attribute onto elements the template compiler knows about. Markup inserted via v-html is created at runtime and gets no such attribute, so scoped selectors never match it. Style v-html content with a non-scoped `<style>` block or the `:deep()` combinator, which pierces the scope boundary for descendant selectors.

Pitfall: computed must be pure

Pitfall
import { ref, computed } from 'vue';
const ids = ref([3, 1, 2]);

// WRONG — getter mutates its dependency
const sorted = computed(() => ids.value.sort());   // sort() mutates in place!

// RIGHT — copy first, never mutate inside a getter
const sorted2 = computed(() => [...ids.value].sort((a, b) => a - b));

// also wrong: async / side effects inside a getter
const bad = computed(() => { fetch('/x'); return 1; });

What it does:A computed getter must be a pure function of its reactive dependencies: no mutation, no async, no side effects. The sneaky case is `Array.prototype.sort()` / `reverse()` / `splice()`, which mutate in place — calling them on a dependency inside a getter corrupts the source and can cause infinite update loops. Always copy (`[...arr]`) before sorting, and put side effects in `watch`, not `computed`.

Pitfall: provide a ref, not its value

Pitfall
import { provide, ref } from 'vue';
const count = ref(0);

// WRONG — provides the number 0; children never see updates
provide('count', count.value);

// RIGHT — provide the ref so descendants stay reactive
provide('count', count);

// later in the parent
count.value++;   // every injecting child re-renders

What it does:When you `provide(key, ref.value)` you hand descendants a frozen snapshot — they get the value at provide time and never see later changes. Provide the ref itself (or a reactive object) so the injected value stays live. Wrap it in `readonly()` if children should read but not write. This mirrors the closure pitfall: pass the reactive container, not the unwrapped value.

What this tool does

A searchable Vue 3 cheat sheet, 80+ real snippets across nine categories. Reactivity: ref / reactive / computed / watch / watchEffect / shallowRef / readonly / toRefs / customRef. Lifecycle: the full onMounted / onUpdated / onUnmounted set plus onErrorCaptured for error boundaries and onActivated / onDeactivated for KeepAlive. Component macros: defineProps in runtime, type-only, and 3.3+ destructure-with-default forms; defineEmits, defineExpose, defineModel (3.4+), defineOptions and defineSlots (3.3+), defineAsyncComponent, Suspense, KeepAlive. Directives: v-if / v-show / v-for with the :key rule, v-model with .lazy / .number / .trim, v-bind with object spread, v-on with the full modifier matrix, v-html with the XSS warning beside the example, v-text / v-cloak / v-pre / v-once, plus a working v-click-outside custom directive. Slots: default, named, scoped (the foundation of headless components), dynamic names. vue-router: setup, dynamic + named, nested, three layers of navigation guards, programmatic navigation, useRoute, lazy-loaded routes. Pinia: both defineStore syntaxes (setup-store recommended), async actions, $patch, parameterized and cross-store getters, storeToRefs, "Pinia outside setup". Composables: writing your own, useMouse / useLocalStorage / useFetch with AbortController, @vueuse/core essentials, provide / inject as DI, useTemplateRef (3.5+). Pitfalls: destructuring reactive loses reactivity, v-for + v-if precedence FLIPPED from Vue 2, watching `obj.x` passes a number not a tracked source, async setup with no Suspense renders nothing, no `this` in `<script setup>`, defineProps default factory rule, composables must be called synchronously, SSR hydration mismatch. Every entry has bilingual EN/ZH descriptions written separately (no machine translation), copyable code, and where it applies an Options API equivalent so Vue 2 migrants see both forms side by side. Pure client-side, no upload.

Tool details

Input
Text
The page exposes text boxes, numeric controls, file pickers, or structured inputs depending on the tool.
Output
Live result + Copy + Preview
The result area focuses on usable output, with copy, download, or preview actions when supported.
Privacy
May use a live lookup
A network call is detected in the component, so redact sensitive data when appropriate.
Save / share
Local preference storage
Preferences, history, or drafts are saved in this browser without an account.
Performance budget
Initial JS <= 30 KB
No WASM budget is declared, keeping the tool quick to open on mobile.
Best fit
Developer & DevOps · Developer
Category and role tags drive related tools, internal links, and quick fit checks.

How to use

  1. 1. Input

    Paste or drop your content into the tool panel.

  2. 2. Process

    Click the button. All processing is local in your browser.

  3. 3. Copy / Download

    Copy the result or download to disk in one click.

How Vue 3 Cheatsheet fits into your work

Use it in the small gaps between coding, reviewing, debugging, and shipping.

Developer jobs

  • Formatting, validating, shrinking, or inspecting code-adjacent text.
  • Preparing snippets for documentation, tickets, commits, or handoff.
  • Checking a small payload quickly without switching tools.

Developer checks

  • Run irreversible transforms like minify or obfuscate on a copy.
  • Keep secrets out of pasted snippets unless the tool explicitly stays local.
  • Use your normal tests or linter before shipping transformed code.

Good next steps

These links move the current task into a more complete workflow.

  1. 1 JSON Formatter & Validator Format, validate, and minify JSON instantly — right in your browser. Open
  2. 2 React Hooks Cheatsheet React hooks cheat sheet — all 17 built-in hooks (useState / useEffect / useMemo / useTransition / useFormStatus...) with real examples and pitfalls. Open
  3. 3 TypeScript Cheatsheet TypeScript cheat sheet — 100+ snippets for types, generics, utility types, narrowing, async patterns. Open

Real-world use cases

  • A Vue 2 dev pasting Options API muscle memory into a new Vue 3 project

    You wrote Vue for three years and now `mounted()` and `data()` just stop existing. Search "lifecycle" here and every Composition hook sits next to its Options twin: `mounted()` beside `onMounted(() => {})`, `data() { count: 0 }` beside `ref(0)`. By day two you stop translating in your head.

  • Debugging a watcher that silently never fires in a PR review

    A teammate writes `watch(obj.x, cb)` and the callback never runs, so a form never validates. Search "watch" and the getter-vs-value trap is right there: `watch(() => obj.x, cb)`. A 30-second lookup replaces an hour of console.log archaeology across four components.

  • Picking ref vs reactive while scaffolding a checkout form

    You start a 12-field checkout and freeze on ref or reactive. The reactivity section spells the rule out: reactive for state you mutate field by field, ref for an API response you replace wholesale, and the destructuring trap that drops reactivity on `const { x } = state`. You decide in 20 seconds, not 20 minutes.

  • Wiring a Pinia store and forgetting it works outside setup

    You call `useCartStore()` inside a router guard and get a "no active pinia" error at 11pm. The Pinia section has the "Pinia outside setup" pattern plus storeToRefs for losing reactivity on destructure, both copyable. The error that ate your evening becomes two lines you paste and move on.

Common pitfalls

  • Watching a reactive property by value, as in `watch(obj.x, cb)`, passes the number once. Wrap it in a getter — `watch(() => obj.x, cb)` — so Vue tracks the source.

  • Destructuring a reactive object — `const { count } = state` — drops reactivity. Use `toRefs(state)` (or `storeToRefs(store)` for Pinia) to keep the link.

  • Running async work in `setup()` with no `<Suspense>` boundary renders nothing at all. Either await inside a child wrapped in Suspense, or keep setup sync and load in onMounted.

Privacy

This cheat sheet is one static page. Your search query filters an in-memory array of snippets in your browser — nothing is sent to a server, nothing lands in the URL, and no code is executed. Open DevTools, Network and type: you will see zero requests. It runs the same behind a corporate proxy or on an air-gapped machine.

FAQ

Tool combos

Folks in your role tend to reach for these alongside this tool.

Made by Toolora · 100% client-side · Updated 2026-06-13