Skip to main content
ASoc
Comparison

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.

The ASoc Team9 min read

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)YesNo — returns the value unwrapped, silently non-reactive
Accepts objects, arrays, Map/SetYes (wraps with reactive() internally)Yes
Access in <script setup>.valueDirect property access
Access in <template>Auto-unwrapped, no .valueDirect
Survives whole-value replacementYes — state.value = newObjectNostate = newObject drops the proxy
Survives destructuringtoRefs not needed for the ref itselfNo — destructured properties are plain values
Works as a function return valueYesOnly 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/:

ShapeCountVue equivalent
Boolean18ref(false)reactive() cannot hold it
String or string union10ref("")reactive() cannot hold it
Number2ref(0)reactive() cannot hold it
Object3ref({}) or reactive({})
Total33

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 /templates grid.
  • 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 = value reads better than form.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 .value on 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

SymptomCauseFix
Value renders once, never updatesA primitive passed to reactive()Use ref(); reactive() silently returns primitives unwrapped
Object stops updating after a "reset"The reactive() object was reassigned, replacing the proxyMutate in place, or switch to ref() and assign .value
Destructured properties are frozenDestructuring a reactive() object yields plain valuestoRefs(state) before destructuring
.value is undefined in a templateWriting count.value in a template where it is already unwrappedDrop .value in templates; keep it in <script setup>
A ref inside an array will not unwrapOnly object properties auto-unwrap, not array or Map entriesAccess .value explicitly, or keep the array itself a ref
Watcher never fires on a reactive() objectWatching a destructured property, or a reassigned rootWatch 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.

Keep reading

Comparison10 min read

Webflow vs Next.js for a Landing Page: How to Actually Decide

The launch-speed argument mostly disappears once you compare template to template. What is left is who edits the copy, what each locks in, and three cases where Webflow wins.

Read more
Comparison12 min read

WordPress vs Next.js for a Marketing Site: The Honest Comparison

Most comparisons pit a WordPress theme against a from-scratch Next.js build. Compare theme to template instead and the real trade turns out to be who edits the copy.

Read more
Comparison10 min read

Angular vs. Svelte: 7 of 24 Client Components Need No State At All

DI-injected signals versus a build-time compiler. Both assume a component needs a reactive primitive — 7 of this codebase's 24 Client Components prove a third of the time, it doesn't.

Read more