Skip to content

Vue 3 reactivity and component design

Knowing ref and computed is only the starting point. On a complex page, the real questions are where state belongs, who is allowed to update it, when dependencies are released, and whether deep reactivity is worth its runtime cost.

The reactivity model

Vue tracks the active effect when reactive data is read and triggers the relevant effects when it is written. reactive wraps objects with a Proxy, while ref provides a consistent .value container for primitives and objects. Templates unwrap refs automatically.

ts
import { computed, reactive, ref, shallowRef } from 'vue'

const query = reactive({ keyword: '', page: 1 })
const rows = shallowRef<Row[]>([])
const selectedIds = ref(new Set<string>())

const selectedCount = computed(() => selectedIds.value.size)

shallowRef is appropriate for a large server response when the application replaces the whole array and does not need deep fields to be proxied. This should still be based on a Vue DevTools or Performance profile, not intuition alone.

Choosing the right primitive

  • Prefer ref for independent values and objects that may be replaced as a whole.
  • reactive works well for an aggregate form, but do not destructure it directly; use toRefs when needed.
  • Use shallowRef or markRaw for large read-only structures and third-party instances.
  • Use computed for derived state instead of synchronizing a second copy through watch.

Watchers are for side effects

Watchers are appropriate for requests, persistence, and third-party SDK calls. Fast-changing input creates races, so stale work must be cancelled.

ts
watch(
  () => query.keyword,
  async (keyword, _, onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    rows.value = await search(keyword, { signal: controller.signal })
  },
)

Avoid watching the complete route object or using unrestricted deep: true on large structures. Watching a specific parameter makes both the cost and the trigger condition easier to reason about and test.

Extract components along axes of change

I typically separate base UI components, domain components, and page orchestration. A shared component needs a stable reuse case, an explicit variation axis, and a testable contract.

vue
<ProductSelector
  v-model="selectedIds"
  :source="productSource"
  :permission="permission"
  @confirm="handleConfirm"
>
  <template #empty>No products match the current filters.</template>
</ProductSelector>

Props and emits define the data and behavior contract; slots handle structural variation. If two use cases have different domain semantics and lifecycles, two focused domain components are often clearer than one component with dozens of boolean props.

Performance diagnosis order

  1. Determine whether the bottleneck is network, JavaScript, layout, or component updates.
  2. Use Vue DevTools for repeated updates and dependency scope; use Performance for long tasks and layout work.
  3. Apply virtualization, stable keys and props, or lazy loading only where the data supports it.
  4. Compare the same interaction and data volume before and after the change.

References: Reactivity in Depth, Watchers, and Performance Best Practices.