[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-vue-destructuring-lost-reactivity":3},{"content":4,"lastModified":5},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>The refactor was this. Vue 3.5 made destructured props reactive, so after years of typing \u003Ccode>props.marketplace\u003C\u002Fcode> everywhere I switched to the short form.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-vue\">&lt;script setup&gt;\nconst { marketplace, status, query } = defineProps(['marketplace', 'status', 'query'])\n\n\u002F\u002F useFilterSync pushes these into the query string.\nuseFilterSync({ marketplace, status })\n&lt;\u002Fscript&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>A sentence in the docs is what fixed it, one I had skimmed past twice. The compiler prepends \u003Ccode>props.\u003C\u002Fcode> to destructured prop accesses, but each access is rewritten individually, and only inside the same \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>So \u003Ccode>useFilterSync\u003C\u002Fcode> was not broken. It was watching a snapshot of the filters from mount time and was never going to hear about them again.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1542831371-29b0f74f9713?w=1200&amp;q=80\" alt=\"Close-up of template and SVG source code on a dark monitor, syntax highlighted in pink and blue\" loading=\"lazy\" \u002F>\n\n\u003Ch2>Where destructured props turn into values\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>watch()\u003C\u002Fcode> is the same as passing \u003Ccode>props.foo\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-vue\">&lt;script setup&gt;\nimport { watch } from 'vue'\n\nconst { marketplace, status } = defineProps(['marketplace', 'status'])\n\n\u002F\u002F Getter: read the prop again on every access.\nwatch(() =&gt; marketplace, (next, prev) =&gt; {\n  trackFilterChange('marketplace', prev, next)\n})\n\n\u002F\u002F Same idea when a composable takes the whole set.\nuseFilterSync(() =&gt; ({ marketplace, status }))\n\n\u002F\u002F And for one prop passed on its own.\nuseDebouncedSearch(() =&gt; marketplace)\n&lt;\u002Fscript&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>props.\u003C\u002Fcode> prefix and reach for \u003Ccode>toRefs\u003C\u002Fcode> when you really need to pull properties out.\u003C\u002Fp>\n\n\u003Ch2>A reactive object has the same weakness\u003C\u002Fh2>\n\n\u003Cp>Plain destructuring is supposed to work on any object, which is the point of it, so it strips the proxy away from a \u003Ccode>reactive()\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const state = reactive({ count: 0, page: 1, sort: 'newest' })\n\n\u002F\u002F Plain variables, disconnected from state.\nconst { count, page } = state\ncount++ \u002F\u002F state.count is untouched\n\n\u002F\u002F Refs that stay linked in both directions.\nconst { count, page } = toRefs(state)\nstate.count++\nconsole.log(count.value) \u002F\u002F 1\n\n\u002F\u002F One property, including one that does not exist yet.\nconst sort = toRef(state, 'sort')\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>There is a shape that reads like the fix and is not one:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const sort = ref(state.sort) \u002F\u002F a new ref holding a plain string\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The docs are blunt about this one. That ref is not synced with \u003Ccode>state.sort\u003C\u002Fcode>. \u003Ccode>toRef\u003C\u002Fcode> keeps the link, \u003Ccode>ref\u003C\u002Fcode> takes a copy. The same distinction explains why \u003Ccode>toRef\u003C\u002Fcode> works on a property that has not been created yet while \u003Ccode>toRefs\u003C\u002Fcode> only covers what was enumerable when you called it.\u003C\u002Fp>\n\n\u003Cp>A cousin of this bites in templates. A ref nested inside a plain object is not unwrapped, so \u003Ccode>{{ object.id + 1 }}\u003C\u002Fcode> renders \u003Ccode>[object Object]1\u003C\u002Fcode>. Only top-level refs get the automatic treatment.\u003C\u002Fp>\n\n\u003Ch2>Pinia stores are reactive objects too\u003C\u002Fh2>\n\n\u003Cp>The store you get from \u003Ccode>useCartStore()\u003C\u002Fcode> is wrapped in \u003Ccode>reactive()\u003C\u002Fcode>, so the Pinia docs say outright that you cannot destructure it. State and getters need \u003Ccode>storeToRefs\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const store = useCartStore()\n\nconst { items, total } = store               \u002F\u002F frozen at their current values\nconst { items, total } = storeToRefs(store)  \u002F\u002F refs, still live\nconst { addItem } = store                    \u002F\u002F fine, actions are functions\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>Reassignment breaks the link the same way\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">let filters = reactive({ page: 1 })\n\nfilters = reactive({ page: 2 }) \u002F\u002F the first proxy is now orphaned\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>My rule since then: if a piece of state might get replaced wholesale, it should have been a \u003Ccode>ref\u003C\u002Fcode> from the start. If it has to stay a reactive object, write into it instead of swapping it out.\u003C\u002Fp>\n\n\u003Ch2>Make the composable accept all three shapes\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">import { watch, toValue, type MaybeRefOrGetter } from 'vue'\n\nexport function useFilterSync(filters: MaybeRefOrGetter&lt;FilterState&gt;) {\n  watch(() =&gt; toValue(filters), (next) =&gt; {\n    \u002F\u002F ...\n  })\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>toValue\u003C\u002Fcode> and the getter form of \u003Ccode>toRef\u003C\u002Fcode> both landed in 3.3, and \u003Ccode>MaybeRefOrGetter\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>I kept the destructured props in the end. Inside a component they read better than \u003Ccode>props.marketplace\u003C\u002Fcode> five times over, and now I only have to be careful about what leaves the block. These snippets live in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> next to the Nuxt version of the same lesson from \u003Ca href=\"\u002Fposts\u002Fnuxt-usefetch-vs-fetch-double-fetch-trap\u002F\">useFetch and $fetch\u003C\u002Fa>, where a value that looked reactive turned out not to be. If React is your daily driver, the near equivalent is \u003Ca href=\"\u002Fposts\u002Freact-context-re-render-why-memo-doesnt-help\u002F\">context value identity\u003C\u002Fa>, and it is just as easy to walk into.\u003C\u002Fp>\n","2026-09-25",1790578737630]