Vue
Purpose
Write Vue 3 with a correct mental model of reactivity. Most Vue bugs are not logic errors — they are a ref that was destructured, or a reactive object that was reassigned, and the view silently stopped updating.
When to Use
- Building or reviewing Vue 3 applications.
- Extracting reusable logic into composables.
- Debugging a value that changes but does not re-render.
- Structuring state with Pinia.
Capabilities
- Composition API with
<script setup>.
- Reactivity:
ref, reactive, computed, watch, watchEffect, and their differences.
- Composables for shared logic with proper lifecycle cleanup.
- Pinia stores, including typed state and actions.
- Performance:
shallowRef, v-memo, and avoiding deep watchers.
Inputs
- The component or composable, and where its state originates.
- The reactivity symptom, if debugging.
Outputs
- Components using
<script setup> with typed props and emits.
- Composables that clean up after themselves.
- Reactivity that survives destructuring and reassignment.
Workflow
- Prefer
ref over reactive — ref survives destructuring (via .value) and reassignment. reactive loses reactivity on both, silently.
- Derive with
computed — Not with a watch that assigns to another ref. That is two renders and a chance to be out of sync.
- Extract composables for shared behavior — Anything that owns a subscription, listener, or timer must clean it up in
onScopeDispose or onUnmounted.
- Type the boundaries —
defineProps<T>() and defineEmits<T>() with type arguments. Runtime-only prop declarations discard type information.
- Watch narrowly — Watch a specific getter, not a whole object with
deep: true. Deep watchers on large objects are a common and invisible performance cost.
Best Practices
- Destructuring a
reactive object breaks reactivity: const { count } = reactive({ count: 0 }) gives you a plain number. Use toRefs or, better, use ref.
- Reassigning a
reactive object (state = { ... }) replaces the proxy and detaches every existing binding. ref does not have this failure mode.
watchEffect tracks whatever it reads — including things you did not intend. watch with an explicit source is more predictable and should be the default.
- A composable that adds an event listener without removing it leaks on every mount.
onScopeDispose handles the composable-in-composable case that onUnmounted does not.
- Prefer
shallowRef for large immutable data structures; deep reactivity on a 10,000-element array is a real cost paid on every mutation.
- Do not mutate props. Emit an event and let the owner change its own state.
Examples
A composable with correct cleanup and derived state:
export function useOrderStream(orderId: Ref<string>) {
const events = ref<OrderEvent[]>([]);
const status = computed(() => events.value.at(-1)?.status ?? "unknown");
const error = ref<Error | null>(null);
let source: EventSource | null = null;
const connect = (id: string) => {
source?.close();
events.value = [];
source = new EventSource(`/api/orders/${id}/stream`);
source.onmessage = (e) => events.value.push(JSON.parse(e.data));
source.onerror = () => { error.value = new Error("stream disconnected"); };
};
watch(orderId, connect, { immediate: true });
onScopeDispose(() => source?.close()); // fires even when used inside another composable
return { events, status, error };
}
Reactivity that silently fails:
// Broken: `count` is a plain number; the template never updates.
const state = reactive({ count: 0 });
const { count } = state;
// Broken: reassignment detaches every binding to the old proxy.
let state = reactive({ items: [] });
state = reactive({ items: newItems });
// Correct: refs survive both.
const count = ref(0);
const items = ref<Item[]>([]);
items.value = newItems;
Notes
v-memo skips re-rendering a subtree when its dependency array is unchanged. It is worth reaching for on large v-for lists and almost nowhere else.
- Pinia stores are reactive objects: destructuring them has the same failure mode as
reactive. Use storeToRefs.
<script setup> compiles props and emits at build time. The runtime defineComponent form still works but discards the type-level checking that makes Vue 3 pleasant.
1---2name: vue3description: Use when building Vue 3 applications. Covers the Composition API, reactivity fundamentals, composables, Pinia state management, and the reactivity mistakes that cause silent update failures.4---56# Vue78## Purpose910Write Vue 3 with a correct mental model of reactivity. Most Vue bugs are not logic errors — they are a `ref` that was destructured, or a `reactive` object that was reassigned, and the view silently stopped updating.1112## When to Use1314- Building or reviewing Vue 3 applications.15- Extracting reusable logic into composables.16- Debugging a value that changes but does not re-render.17- Structuring state with Pinia.1819## Capabilities2021- Composition API with `<script setup>`.22- Reactivity: `ref`, `reactive`, `computed`, `watch`, `watchEffect`, and their differences.23- Composables for shared logic with proper lifecycle cleanup.24- Pinia stores, including typed state and actions.25- Performance: `shallowRef`, `v-memo`, and avoiding deep watchers.2627## Inputs2829- The component or composable, and where its state originates.30- The reactivity symptom, if debugging.3132## Outputs3334- Components using `<script setup>` with typed props and emits.35- Composables that clean up after themselves.36- Reactivity that survives destructuring and reassignment.3738## Workflow39401. **Prefer `ref` over `reactive`** — `ref` survives destructuring (via `.value`) and reassignment. `reactive` loses reactivity on both, silently.412. **Derive with `computed`** — Not with a `watch` that assigns to another ref. That is two renders and a chance to be out of sync.423. **Extract composables for shared behavior** — Anything that owns a subscription, listener, or timer must clean it up in `onScopeDispose` or `onUnmounted`.434. **Type the boundaries** — `defineProps<T>()` and `defineEmits<T>()` with type arguments. Runtime-only prop declarations discard type information.445. **Watch narrowly** — Watch a specific getter, not a whole object with `deep: true`. Deep watchers on large objects are a common and invisible performance cost.4546## Best Practices4748- Destructuring a `reactive` object breaks reactivity: `const { count } = reactive({ count: 0 })` gives you a plain number. Use `toRefs` or, better, use `ref`.49- Reassigning a `reactive` object (`state = { ... }`) replaces the proxy and detaches every existing binding. `ref` does not have this failure mode.50- `watchEffect` tracks whatever it reads — including things you did not intend. `watch` with an explicit source is more predictable and should be the default.51- A composable that adds an event listener without removing it leaks on every mount. `onScopeDispose` handles the composable-in-composable case that `onUnmounted` does not.52- Prefer `shallowRef` for large immutable data structures; deep reactivity on a 10,000-element array is a real cost paid on every mutation.53- Do not mutate props. Emit an event and let the owner change its own state.5455## Examples5657**A composable with correct cleanup and derived state:**5859```typescript60export function useOrderStream(orderId: Ref<string>) {61 const events = ref<OrderEvent[]>([]);62 const status = computed(() => events.value.at(-1)?.status ?? "unknown");63 const error = ref<Error | null>(null);6465 let source: EventSource | null = null;6667 const connect = (id: string) => {68 source?.close();69 events.value = [];70 source = new EventSource(`/api/orders/${id}/stream`);71 source.onmessage = (e) => events.value.push(JSON.parse(e.data));72 source.onerror = () => { error.value = new Error("stream disconnected"); };73 };7475 watch(orderId, connect, { immediate: true });76 onScopeDispose(() => source?.close()); // fires even when used inside another composable7778 return { events, status, error };79}80```8182**Reactivity that silently fails:**8384```typescript85// Broken: `count` is a plain number; the template never updates.86const state = reactive({ count: 0 });87const { count } = state;8889// Broken: reassignment detaches every binding to the old proxy.90let state = reactive({ items: [] });91state = reactive({ items: newItems });9293// Correct: refs survive both.94const count = ref(0);95const items = ref<Item[]>([]);96items.value = newItems;97```9899## Notes100101- `v-memo` skips re-rendering a subtree when its dependency array is unchanged. It is worth reaching for on large `v-for` lists and almost nowhere else.102- Pinia stores are reactive objects: destructuring them has the same failure mode as `reactive`. Use `storeToRefs`.103- `<script setup>` compiles props and emits at build time. The runtime `defineComponent` form still works but discards the type-level checking that makes Vue 3 pleasant.