Vue Reactivity Breaks on Destructuring: Four Places I Check
I lost most of last Tuesday morning to a bug I had created with a refactor I was pleased with. The filter sidebar rendered fine, every count was correct, every input wrote into the right piece of state, and Vue reactivity looked healthy from every angle. The only part that had stopped working was the query string, which was still showing whatever the filters were when the page first loaded.
The refactor was this. Vue 3.5 made destructured props reactive, so after years of typing props.marketplace everywhere I switched to the short form.
<script setup>
const { marketplace, status, query } = defineProps(['marketplace', 'status', 'query'])
// useFilterSync pushes these into the query string.
useFilterSync({ marketplace, status })
</script>
A sentence in the docs is what fixed it, one I had skimmed past twice. The compiler prepends props. to destructured prop accesses, but each access is rewritten individually, and only inside the same <script setup> block. In a template or a watch getter that means a fresh read every time. In an object literal you hand to a function, it means one read, then a plain string traveling onward.
So useFilterSync was not broken. It was watching a snapshot of the filters from mount time and was never going to hear about them again.
Where destructured props turn into values
Anywhere a prop leaves the block, the link is gone. Vue catches the obvious version and warns in development: passing a destructured prop straight into watch() is the same as passing props.foo, which is a value where a reactive source is expected. The fix is a getter, and it applies to every function the prop gets handed to.
<script setup>
import { watch } from 'vue'
const { marketplace, status } = defineProps(['marketplace', 'status'])
// Getter: read the prop again on every access.
watch(() => marketplace, (next, prev) => {
trackFilterChange('marketplace', prev, next)
})
// Same idea when a composable takes the whole set.
useFilterSync(() => ({ marketplace, status }))
// And for one prop passed on its own.
useDebouncedSearch(() => marketplace)
</script>
My composable took its filters as a plain object on purpose. A getter looked like overkill for something that already felt reactive, which is exactly how this kind of thing survives code review.
One footnote for anyone on an older release: before 3.5, destructured props were plain constants with no compiler rewriting at all. If that is your project, keep the props. prefix and reach for toRefs when you really need to pull properties out.
A reactive object has the same weakness
Plain destructuring is supposed to work on any object, which is the point of it, so it strips the proxy away from a reactive() source just as happily. Vue documents it as a limitation of the API. Primitive properties come out as constants, and passing one of them into a function hands over a number.
const state = reactive({ count: 0, page: 1, sort: 'newest' })
// Plain variables, disconnected from state.
const { count, page } = state
count++ // state.count is untouched
// Refs that stay linked in both directions.
const { count, page } = toRefs(state)
state.count++
console.log(count.value) // 1
// One property, including one that does not exist yet.
const sort = toRef(state, 'sort')
There is a shape that reads like the fix and is not one:
const sort = ref(state.sort) // a new ref holding a plain string
The docs are blunt about this one. That ref is not synced with state.sort. toRef keeps the link, ref takes a copy. The same distinction explains why toRef works on a property that has not been created yet while toRefs only covers what was enumerable when you called it.
A cousin of this bites in templates. A ref nested inside a plain object is not unwrapped, so {{ object.id + 1 }} renders [object Object]1. Only top-level refs get the automatic treatment.
Pinia stores are reactive objects too
The store you get from useCartStore() is wrapped in reactive(), so the Pinia docs say outright that you cannot destructure it. State and getters need storeToRefs, which walks the store and builds a ref for every reactive property, skipping anything that is not a ref or a reactive object. Actions are already bound to the store, so those come off directly.
const store = useCartStore()
const { items, total } = store // frozen at their current values
const { items, total } = storeToRefs(store) // refs, still live
const { addItem } = store // fine, actions are functions
Pinia's own example is worth reading because of how plausible the broken version looks. Destructure a counter and its doubled getter, call an action a second later, and both variables are still sitting on their original values.
Reassignment breaks the link the same way
Reactivity is tracked per property access, which is why the docs insist you keep one reference to a reactive object for its whole life. Reach for the variable and swap in a new one, and the proxy that every effect was tracking is left behind.
let filters = reactive({ page: 1 })
filters = reactive({ page: 2 }) // the first proxy is now orphaned
My rule since then: if a piece of state might get replaced wholesale, it should have been a ref from the start. If it has to stay a reactive object, write into it instead of swapping it out.
Make the composable accept all three shapes
The real fix belonged in the composable rather than at the call site, so I wrote it to accept that callers will pass values, refs, and getters, and handle all three.
import { watch, toValue, type MaybeRefOrGetter } from 'vue'
export function useFilterSync(filters: MaybeRefOrGetter<FilterState>) {
watch(() => toValue(filters), (next) => {
// ...
})
}
toValue and the getter form of toRef both landed in 3.3, and MaybeRefOrGetter gives callers a type to match. Together they take the getter-or-value question out of code review, because callers no longer have to think about which shape they are holding.
I kept the destructured props in the end. Inside a component they read better than props.marketplace five times over, and now I only have to be careful about what leaves the block. These snippets live in Snippet Ark next to the Nuxt version of the same lesson from useFetch and $fetch, where a value that looked reactive turned out not to be. If React is your daily driver, the near equivalent is context value identity, and it is just as easy to walk into.