Vue ref vs. reactive: A 33-State Census Says ref
reactive() cannot hold a primitive, and 30 of this codebase's 33 state values are primitives. Of the 3 objects left, 2 do the thing reactive() breaks on.
Every Vue 3 guide answers ref vs reactive with the same two toy examples: a counter for ref, a form object for reactive. Both are true and neither tells you how often each case actually occurs. So we counted — 33 state declarations in a production storefront, classified by the one property that decides the answer.
The short answer
Use ref(). It accepts primitives and objects, survives whole-value reassignment, and is Vue's recommended default. reactive() only accepts objects, and loses reactivity the moment you replace the object rather than mutate it. In a real codebase, primitives outnumber objects roughly ten to one — which makes reactive() the exception, not the coin flip.
The two APIs, on the axes that decide it
ref() | reactive() | |
|---|---|---|
| Accepts primitives (string, number, boolean) | Yes | No — returns the value unwrapped, silently non-reactive |
Accepts objects, arrays, Map/Set | Yes (wraps with reactive() internally) | Yes |
Access in <script setup> | .value | Direct property access |
Access in <template> | Auto-unwrapped, no .value | Direct |
| Survives whole-value replacement | Yes — state.value = newObject | No — state = newObject drops the proxy |
| Survives destructuring | toRefs not needed for the ref itself | No — destructured properties are plain values |
| Works as a function return value | Yes | Only if the caller never reassigns it |
Two rows in that table are the whole argument, and both are about replacement rather than shape. reactive() hands you a Proxy around a specific object identity. Reactivity is a property of that identity, so anything that swaps the identity — reassignment, destructuring, spreading into a new object — steps outside the proxy and takes the reactivity with it. ref() keeps the identity in the wrapper's .value slot, so the slot survives whatever you put in it.
The census: what state actually looks like
Arguments about ref vs reactive usually assume the two cases are roughly balanced. This storefront is a Next.js + Tailwind marketplace — 92 component files, 28 of them marked "use client" — and every piece of client state in it lives in a useState call. React's useState and Vue's reactivity primitives are not the same thing, but they answer the same question, what shape is this value, and that shape is exactly what decides ref vs reactive.
Counting every useState call site in src/:
| Shape | Count | Vue equivalent |
|---|---|---|
| Boolean | 18 | ref(false) — reactive() cannot hold it |
| String or string union | 10 | ref("") — reactive() cannot hold it |
| Number | 2 | ref(0) — reactive() cannot hold it |
| Object | 3 | ref({}) or reactive({}) |
| Total | 33 |
Thirty of the thirty-three are primitives. reactive() is not merely the wrong choice for those — it is not a choice at all, because passing a primitive to reactive() returns the value untouched and nothing is reactive afterwards. In development Vue warns; the warning is easy to miss, and the failure mode is a value that renders once and never updates.
That leaves three candidates in the entire codebase. Here they are, with what each one does:
src/components/organisms/TemplatesExplorer.tsx— the catalog filter set (category,framework,status,pricing) driving the/templatesgrid.src/lib/useOwnedProducts.ts— the memoized ownership lookup that tells every card on a page whether the viewer already bought that product.src/components/molecules/PreviewModal.tsx— the measured stage dimensions ({ w, h }) used to scale a desktop-width iframe down to fit a phone.
Three out of thirty-three, or 9%. And that is before the interesting part.
Two of the three do the thing reactive() breaks on
Having an object is a necessary condition for reactive(), not a sufficient one. The sufficient condition is that you mutate the object in place for its whole life. Here is the filter state, copied from TemplatesExplorer.tsx:
const [filters, setFilters] = useState<TemplateFilters>({});
const results = filterTemplates(catalog, filters);
// ...one of four identical handlers:
onChange={(category) => setFilters((f) => ({ ...f, category }))}
Every update builds a new object from a spread. That is idiomatic React, where state is immutable by contract — but it is also the precise operation that reactive() cannot survive. The direct Vue translation of that handler loses reactivity:
// Broken: reassignment replaces the proxy with a plain object
const filters = reactive({});
const setCategory = (category) => {
filters = { ...filters, category }; // the proxy is gone
};
You can write it correctly with reactive() by mutating instead:
const filters = reactive({});
const setCategory = (category) => {
filters.category = category; // fine — mutation, not replacement
};
That works. But now clearing all filters is Object.keys(filters).forEach(k => delete filters[k]) rather than filters.value = {}, and passing filters to a composable means trusting that nothing downstream ever reassigns it. With ref() both operations are ordinary:
const filters = ref({});
const setCategory = (category) => {
filters.value = { ...filters.value, category };
};
const clear = () => {
filters.value = {};
};
The ownership hook has the same shape. From src/lib/useOwnedProducts.ts:
const [state, setState] = useState<OwnedProducts>({
status: COMMERCE_ENABLED ? "loading" : "ready",
...NOBODY,
});
useEffect(() => {
if (!COMMERCE_ENABLED) return;
let cancelled = false;
void loadOwnership().then((result) => {
if (!cancelled) setState({ status: "ready", ...result });
});
return () => { cancelled = true; };
}, []);
setState({ status: "ready", ...result }) replaces the entire object when an async lookup resolves. That is the canonical async-state transition — loading to ready, atomically, so no render ever sees a half-updated object — and it is whole-object replacement again. reactive() would force you to either mutate field by field (briefly exposing an inconsistent state to anything watching) or wrap the object in another object so the outer identity stays stable, which is what ref() already is.
So of three object-shaped states, two replace the object wholesale. The honest tally for reactive() in this codebase is one state out of thirty-three — the PreviewModal stage dimensions, which are written once after a measurement and then read.
When reactive() is genuinely the better call
It is not a deprecated API, and the case for it is real:
- A form model with many fields, mutated field by field.
form.email = valuereads better thanform.value.email = value, and a long-lived form object is rarely replaced wholesale. - A store-like object passed around by reference, where every consumer mutates properties and nobody reassigns the root.
- Deeply nested state you mutate at the leaves. Both APIs are deep-reactive, but
reactive()avoids.valueon every access path.
The common thread is mutation over a stable identity. If you can say "this object exists for the lifetime of the component and only its fields change", reactive() earns its place. If you cannot promise that, ref() is the one that keeps working.
Mixing them is fine, and mostly invisible
ref(someObject) calls reactive() on the object internally, so a ref holding an object gives you deep reactivity plus a replaceable outer slot. That is why "just use ref" is not a compromise — it is reactive() with an escape hatch.
The one place the mixture surprises people is nesting. A ref inside a reactive() object is auto-unwrapped:
const count = ref(0);
const state = reactive({ count });
state.count; // 0, not a ref — unwrapped
But a ref inside a plain object, or inside an array, is not:
const list = reactive([ref(0)]);
list[0].value; // still a ref — arrays and Maps do not unwrap
That inconsistency is the strongest practical argument for picking one primitive and staying with it. If every stateful value in a component is a ref, .value is a uniform rule rather than something you have to check per access.
Templates never need .value at all — Vue unwraps top-level refs during render — so the .value tax is confined to <script setup>, which is where the counting above says 30 of 33 values could not have used reactive() regardless.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Value renders once, never updates | A primitive passed to reactive() | Use ref(); reactive() silently returns primitives unwrapped |
| Object stops updating after a "reset" | The reactive() object was reassigned, replacing the proxy | Mutate in place, or switch to ref() and assign .value |
| Destructured properties are frozen | Destructuring a reactive() object yields plain values | toRefs(state) before destructuring |
.value is undefined in a template | Writing count.value in a template where it is already unwrapped | Drop .value in templates; keep it in <script setup> |
| A ref inside an array will not unwrap | Only object properties auto-unwrap, not array or Map entries | Access .value explicitly, or keep the array itself a ref |
Watcher never fires on a reactive() object | Watching a destructured property, or a reassigned root | Watch a getter: watch(() => state.field, ...) |
Frequently asked questions
Is ref or reactive better in Vue 3?
ref() for nearly everything. It is the only one that handles primitives, it survives reassignment, and Vue's own documentation presents it as the primary API. Reach for reactive() when you have a long-lived object that is only ever mutated field by field.
Can I use reactive with a string or number?
No. reactive() requires an object type. Passing a primitive returns it unchanged and non-reactive, with a development-mode warning. In the codebase counted above that rules reactive() out for 30 of 33 state values.
Why does my reactive object stop being reactive?
Almost always because it was replaced rather than mutated — state = { ...state, x } or state = {}. Reactivity belongs to the original proxy identity; assigning a new object discards it. This is the failure ref() does not have.
Does ref have a performance cost over reactive?
Not meaningfully. ref() wraps objects with reactive() internally, so an object in a ref has the same proxy machinery plus one property access. The difference is not something an application will measure.
Should I use ref for every value, even objects?
Yes, as a default. A single rule — everything is a ref, .value in script, nothing in templates — removes the per-value decision and the unwrapping inconsistencies that come with mixing. Switch a specific value to reactive() when the mutation-only pattern makes it clearly nicer to read.
Where this fits in the wider comparison
The ref/reactive split is Vue-internal, but it points at something structural: Vue needs a reactivity primitive for every stateful value, on the server as much as the client. Next.js vs. Vue walks the same storefront from the other direction — where a component can skip the primitive entirely because it never ships to the browser. And if the question underneath is really about tooling rather than reactivity, Vite vs. Vue untangles the layer confusion behind it.
Templates in this post
ASoc Coin, ASoc Compound and ASoc Cortex are production landing page templates with their client/server boundaries already drawn — state lives only where it has to.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
