DevCerts logo DevCerts

Vue Reactivity Mistakes: Too Much State, Watch Chaos, and Heavy Computed

Many Vue performance issues are not caused by slow rendering alone. They come from oversized reactive graphs, uncontrolled watchers, and computed properties that hide expensive work or trigger circular updates.

Vue
Vue Reactivity Mistakes: Too Much State, Watch Chaos, and Heavy Computed

Vue applications rarely become hard to maintain because one component is badly written. They usually degrade because the reactive model grows without boundaries. More state becomes reactive than necessary, watch handlers start coordinating business logic, and computed properties turn into hidden data pipelines.

The result is familiar in production: interaction latency becomes inconsistent, dependencies are hard to trace, and fixing one update path creates another update loop. The problem is not that Vue reactivity is unsafe. The problem is treating reactivity as a default storage mechanism instead of a precise dependency model.

The core mistake: making everything reactive

Vue’s reactivity system is designed to track dependencies and update consumers when those dependencies change. That is useful for UI state, derived UI values, and controlled side effects. It is less useful for large static payloads, service instances, configuration objects, cached API responses, and data that is only passed through.

A common mistake is wrapping entire objects in reactive() because it feels consistent:

import { reactive } from 'vue'

const state = reactive({
  filters: {
    status: 'active',
    search: '',
    tags: []
  },
  schema: largeFormSchema,
  apiClient,
  permissionsMatrix,
  rows: hugeResponseFromServer
})

This looks convenient, but it creates a broad dependency surface. Any component, computed property, or watcher that touches nested values can become part of the update graph. Over time, the team no longer knows which reads are cheap, which are tracked, and which updates can fan out through the UI.

A tighter model separates reactive UI state from non-reactive resources:

import { reactive, shallowRef, markRaw } from 'vue'

const filters = reactive({
  status: 'active',
  search: '',
  tags: []
})

const rows = shallowRef([])
const schema = markRaw(largeFormSchema)
const client = markRaw(apiClient)

async function loadRows() {
  rows.value = await client.fetchRows(filters)
}

Here, filters are reactive because the UI depends on them. rows are replaced as a unit, which is usually easier to reason about than tracking every nested cell. schema and client are explicitly excluded from deep tracking because they are not UI state.

Reactivity should describe what can change the screen, not everything the component happens to hold.

Excessive reactivity increases debugging cost

The main cost of excessive reactivity is not always CPU usage. It is often dependency ambiguity. When too much data is reactive, every read becomes a possible subscription and every write becomes a possible trigger.

That affects several areas:

  • Rendering, because more dependencies may invalidate more consumers.

  • Testing, because behavior depends on implicit subscriptions rather than explicit inputs.

  • Refactoring, because moving one read into a computed value may change when updates happen.

  • Debugging, because dependency chains become scattered across components, composables, and watchers.

This is why performance problems in Vue applications often feel non-local. A slow interaction may begin with a simple input change, then trigger a watcher, then update derived state, then invalidate a computed property, then cause another watcher to run.

watch chaos: when side effects become architecture

watch is useful when a reactive change must cause an external side effect. Examples include API calls, persistence, analytics, browser APIs, and integration with non-Vue systems.

It becomes dangerous when it is used as a general control-flow mechanism.

import { ref, watch } from 'vue'

const search = ref('')
const page = ref(1)
const query = ref({})

watch(search, value => {
  page.value = 1
  query.value = {
    ...query.value,
    search: value,
    page: page.value
  }
})

watch(page, value => {
  query.value = {
    ...query.value,
    page: value
  }
})

watch(query, async value => {
  await fetchResults(value)
}, { deep: true })

This code has several problems. The query is not a source of truth, it is a synchronization artifact. The first watcher updates page, which is watched elsewhere. The third watcher depends on an object created by other watchers. A future change can easily introduce duplicate requests or cyclic updates.

A more maintainable approach is to make the query derived and keep the side effect at the boundary:

import { ref, computed, watch } from 'vue'

const search = ref('')
const page = ref(1)

const query = computed(() => ({
  search: search.value.trim(),
  page: page.value
}))

watch(query, async currentQuery => {
  await fetchResults(currentQuery)
}, { immediate: true })

Now the model is clearer:

  1. search and page are inputs.

  2. query is derived state.

  3. fetchResults is the side effect.

The watcher does not coordinate internal state. It observes a stable boundary and performs one external action.

The table: shortcut patterns versus production-grade patterns

Pattern

Runtime behavior

Debugging complexity

Failure mode

Better replacement

Deep reactive API response

High dependency surface

High

Large invalidation graph

shallowRef with replacement updates

Watcher updates another watched value

Cascading updates

High

Duplicate work or loops

Single derived computed source

Computed property with side effects

Unpredictable invalidation

High

Hidden writes during reads

Pure computed, side effects in watch

Reactive service or class instance

Unclear tracking

Medium

Proxy-related surprises

markRaw for non-UI objects

Deep watcher on a large object

Broad traversal cost

High

Work triggered by unrelated fields

Watch specific fields or a normalized key

This table is not a rulebook. It is a diagnostic tool. If a pattern increases the number of implicit dependencies, it usually increases maintenance risk.

Complex computed properties hide expensive work

A computed property should be a pure, cacheable derivation from reactive inputs. It should not mutate state, trigger requests, write to storage, or normalize an entire domain model every time the UI needs a label.

A common failure pattern is using computed as a convenient place to run heavy transformations:

const visibleRows = computed(() => {
  return rows.value
    .map(row => enrichRow(row, permissions.value, schema))
    .filter(row => matchesFilters(row, filters))
    .sort((a, b) => a.title.localeCompare(b.title))
})

This may be acceptable for small lists. It becomes harder to justify when rows is large, enrichRow is non-trivial, or filters change on every keystroke. The computed value is still pure, but it now mixes enrichment, filtering, sorting, permission checks, and presentation rules.

A better structure separates stable transformations from frequently changing ones:

const enrichedRows = computed(() => {
  return rows.value.map(row => enrichRow(row, permissions.value, schema))
})

const normalizedSearch = computed(() => filters.search.trim().toLowerCase())

const visibleRows = computed(() => {
  return enrichedRows.value
    .filter(row => matchesSearch(row, normalizedSearch.value))
    .filter(row => matchesStatus(row, filters.status))
})

This does not magically remove computation. It makes dependencies visible. If only filters.search changes, the team can reason about which part should rerun. If enrichment is still too expensive, it can be moved closer to data loading, memoized by ID, or performed server-side depending on the workload.

Circular updates are usually design bugs

Circular updates often appear when the application has multiple competing sources of truth. For example, a component may sync route query parameters into local state, then watch local state and write back to the route.

watch(
  () => route.query.page,
  value => {
    page.value = Number(value || 1)
  },
  { immediate: true }
)

watch(page, value => {
  router.replace({
    query: {
      ...route.query,
      page: String(value)
    }
  })
})

This can work, but it is fragile. The route and the local ref both claim to represent the current page. Even when Vue avoids obvious infinite loops, the application may still produce redundant updates, repeated navigation calls, or confusing test behavior.

A safer design chooses one writable source of truth and derives the other representation:

const page = computed({
  get() {
    return Number(route.query.page || 1)
  },
  set(value) {
    router.replace({
      query: {
        ...route.query,
        page: String(value)
      }
    })
  }
})

This makes the route the source of truth. The component interacts with page, but the update path is explicit. There is no separate watcher trying to keep two writable values synchronized.

Practical rules for senior Vue code reviews

When reviewing Vue code, the important question is not “does it work?” The better question is “can we predict what updates when this value changes?”

Use these checks in real code reviews:

  • Treat reactive() as a design decision, not a default.

  • Prefer ref or shallowRef when replacement semantics are enough.

  • Keep computed properties pure and side-effect free.

  • Use watch for external effects, not for internal state choreography.

  • Avoid deep watchers unless the watched object is small and intentionally modeled as one unit.

  • Keep route state, form state, store state, and server state from duplicating each other.

  • Split large computed pipelines when different stages depend on different inputs.

  • Name derived values after what they represent, not after how they are built.

These rules are especially important in larger teams. Reactive code that depends on implicit timing and hidden dependencies becomes harder to test, harder to onboard into, and harder to optimize safely.

What to adopt first

The fastest improvement is usually not a rewrite. Start by finding the parts of the application where updates are difficult to explain.

Good first targets include:

  1. Components with multiple watchers that write to each other’s dependencies.

  2. Large computed properties that transform, filter, sort, and format data in one place.

  3. Deep watchers on form models, table data, or API responses.

  4. Stores that keep both raw data and several mutable derived copies.

  5. Components that sync the same state between props, route query, local refs, and global stores.

For each target, reduce the number of writable sources first. Then move pure derivation into computed. Finally, keep side effects in a small number of watchers at clear boundaries.

If Vue architecture is part of your day-to-day work and you want a structured way to validate senior-level practical judgment, the most relevant certification to review is Senior Vue Developer.


Conclusion

Vue performance problems are often architecture problems expressed through reactivity. Too much reactive state creates wide dependency graphs. Watcher-heavy control flow makes updates difficult to predict. Complex computed properties hide work that should be split, cached, moved, or made explicit.

The practical fix is to narrow the reactive surface. Keep sources of truth few, make derived values pure, and reserve watchers for side effects at system boundaries. That gives the team a codebase where updates are easier to trace, performance issues are easier to isolate, and future changes are less likely to create circular behavior.