Vue – Reactivity: ref and reactive

October 21, 20254 min readUpdated 8/24/2026

Reactivity is the part of Vue you have to actually understand. Everything else is syntax around it. The idea is simple: you declare state, you use it in a template, and when the state changes the template updates — without you telling it to.

The mechanism is dependency tracking. While a component renders, Vue records every reactive value that render read. When one of those values changes, Vue re-runs that render and nothing else. There is no dependency array to maintain and no equality function to pass, because Vue already knows exactly which renders touched which values.

ref

ref() wraps a value in a reactive container:

import { ref } from "vue";

const loading = ref(true);
const reels = ref([]);
const activeIndex = ref(0);

In JavaScript you read and write through .value:

loading.value = false;
reels.value.push(...res.items);
if (activeIndex.value === 0) { /* ... */ }

In a template you do not:

<LoadingSpinner v-if="loading" />
<p>{{ reels.length }} reels</p>

That inconsistency is the single most common source of early confusion, so it is worth knowing why it exists. .value is the only way to make a plain value reactive in JavaScript — assigning to a bare variable is invisible to any library, because there is no hook to intercept it. A property access is interceptable, so ref gives the value a property. Templates are compiled, so the compiler knows which bindings are refs and unwraps them for you.

Forgetting .value

This is the bug you will write in your first week:

const loading = ref(true);

if (loading) { /* ALWAYS true -- an object is truthy */ }
if (loading.value) { /* correct */ }

loading = false;         // TypeError: assignment to constant
loading.value = false;   // correct

The const assignment throws, which is loud and easy. The truthiness one is silent and will cost you an afternoon. If a condition that should sometimes be false never is, this is why.

ref holds anything

Not just primitives. A ref containing an object or array is deeply reactive — mutating something nested triggers updates:

  const items = ref([]);

  function push(message, variant = "success", ms = 3600) {
    const id = nextId++;
    items.value.push({ id, message, variant });
    setTimeout(() => dismiss(id), ms);
    return id;
  }

items.value.push(...) mutates the array in place and the toast list re-renders. Vue wraps the array in a proxy, so the mutation is observable.

reactive

The other way to declare state. reactive() takes an object and returns a reactive proxy of it, with no .value anywhere:

import { reactive } from "vue";

const filters = reactive({ status: "", creator: "", q: "" });

filters.status = "PUBLISHED";   // no .value
console.log(filters.q);

That looks nicer, and it is why people reach for it. Then they hit the limitations.

Three ways reactive will surprise you

1. It only works on objects. reactive(0) and reactive("hello") do nothing at all — there is no object to proxy. Primitives need ref, so a codebase using reactive ends up using both anyway.

2. Destructuring breaks it. This is the big one:

const filters = reactive({ status: "", q: "" });

// `status` is now a plain string, disconnected from the proxy. It will never
// update again, and nothing warns you.
const { status } = filters;

The same applies to passing filters.status into a function: you hand over a value, not a reference to reactive state.

3. Reassignment breaks it. The reactivity belongs to the proxy object, so replacing the object throws it away:

let filters = reactive({ status: "" });

filters = { status: "PUBLISHED" };   // reactivity lost -- this is a plain object now

// With a ref, replacing the whole value is fine and is the normal thing to do:
const filtersRef = ref({ status: "" });
filtersRef.value = { status: "PUBLISHED" };   // still reactive

Which to use

Use ref for everything unless you have a specific reason not to. That is the rule this track follows and the rule the demo application follows — it contains 78 ref calls and not a single reactive.

The reasoning: ref works for every type, survives destructuring and reassignment, and is what composables return anyway. The price is .value, which is a small, consistent, visible tax. reactive removes that tax and replaces it with three failure modes that are all silent.

Vue's own documentation now recommends ref as the default for the same reasons.

toRef(obj, 'key') creates a ref that stays linked to one property of a reactive object, and toRefs(obj) does it for every property at once — which is how you destructure a reactive without breaking it. If you follow the rule above you will rarely need either, but you will read them in other people's code.

shallowRef makes only the .value assignment reactive, not anything nested. It is a performance tool for large objects you replace wholesale rather than edit; lesson 25 covers when it is worth it.

readonly(obj) returns a proxy that warns on any write. It is the right way to hand state to a child that should only read it, and it comes up again in lesson 16.

What reactivity does not do

It tracks reads during a render or an effect. Two consequences:

A value read outside any reactive context — in a plain module-level function, say — is not tracked, and changing it updates nothing.

An async function loses tracking after its first await. Anything read after the await is not registered as a dependency. This matters for watchers, and lesson 7 comes back to it.

Next: Computed Properties — derived state that caches itself.