Vue's Magical Reactivity Has Some Quirks

Vue's Magical Reactivity Has Some Quirks

Why does Vue's reactivity sometimes behave unexpectedly? Explore ref unwrapping edge cases and cloning complications with clear examples and fixes.

Abdel Awad

Abdel Awad

September 30, 2025

Vue's Magical Reactivity Has Some Quirks

You know that magical feeling when you first discover Vue's reactivity? Everything just works. Update a variable, DOM updates. Change an array, components re-render. It's like the framework can read your mind.

But here's the thing ... that magic has some quirks. And if you've been using Vue for a while, you've probably stumbled into one of these quirks and spent an embarrassing amount of time debugging what should have been simple.

Today, we are checking out a couple of caveats in Vue's reactivity system to show you exactly where it gets weird, why it happens, and most importantly ... how to work with it instead of against it.

Reactivity Primer

You can create reactive references in Vue.js using one of the following functions:

reactive()

Returns a reactive version (proxy) of the object, meaning it only works with object types like plain objects {}, arrays, sets, maps, and more.

It works by iterating over the individual properties of the object and recursively calling itself if those properties' values happen to be an object type. This function is a bit controversial. Many voices in the community don't like using it, myself included.

      const obj = reactive({ count: 0 })
obj.count++

    

ref()

Takes an inner value and returns a reactive and mutable ref object, which has a single property .value that points to the inner value.

      const count = ref(0)
console.log(count.value) // 0

count.value = 1
console.log(count.value) // 1

count.value++;
console.log(count.value) // 2

    

This function is the most commonly used in the Vue.js ecosystem, so it's pretty standard. But the interesting thing about ref is that internally, if you pass in an object as the inner value, it calls reactive under the hood for all the nested properties in that object.

The only difference is ref always produces a wrapper object for the inner value that you need to access or mutate with .value, regardless of whether the value is an object or a primitive type (e.g., string, number, etc.). Meanwhile, since reactive only works with object types, it will always return a reactive proxy of that object, so no wrapper object is produced.

shallowRef()

Similar to ref but doesn't recursively iterate over all nested properties to make them reactive. The main use case for it is performance optimizations of large data structures or integration with external state management systems. It works perfectly if you can absolutely ensure all your data mutations follow immutable patterns.

We will talk about it more in the upcoming sections.

Nested Refs Unwrapping

The first thing that can be very confusing is having nested ref objects inside your already reactive objects. The main thing to look out for is that reactive automatically unwraps any nested refs it can find, no matter how deep the object structure is.

This means that if you do something like this:

      const count = ref(1);
const obj = reactive({ count });

    

The reactive call here will go over the properties of the given object and convert any refs it can find to plain values.

So if you do this it would actually crash:

      obj.count.value++;

    

Even though count was a ref, when accessed from obj it will be unwrapped, so it won't have a value property! But the original count ref would still be "wrapped." So here are the different ways you could increment the count:

      // it will update `obj.count`
// This is the ref, so it is still wrapped.
count.value++
console.log(count.value) // 2
console.log(obj.count) // 2

// it will also update `count` ref
// Accessing it from `obj`, which means it got unwrapped
// No `.value` here!
obj.count++
console.log(obj.count) // 3
console.log(count.value) // 3

    

This can be very confusing, so you might ask yourself:

Why would I even need to do this?

Well, there are several use cases, but I found most of them to be present when working with "context aware" components via provide/inject.

Consider a CheckboxGroup component with Checkbox components to create a customizable radio-button-like ecosystem of components. The CheckboxGroup needs to be aware of the child component's state to properly "manage" them.

For example the group needs to be aware of each checkbox's checked and focused values. So you could do something like this when implementing a group context:

      const checkboxes = ref([]);

function addCheckbox(checkboxState) {
  checkboxes.value.push(checkboxState);
}

provide('GROUP_CTX', {
  addCheckbox
});

    

Then each checkbox would "register" itself like so:

      const checked = ref(false);
const focused = ref(false);

const group = inject('GROUP_CTX');

group.addCheckbox({ checked, focused });

    

Now the group would have access to all its child components state and can actually toggle the state for them directly without back and forth communication. This is a very common pattern in many UI libraries that you might be using right now in your Vue.js projects.

But wait a second. You might be thinking that we're not using reactive here, right?

Wrong! Remember the difference between ref and shallowRef? ref actually calls reactive under the hood if the inner value happens to be an object type, and arrays are considered an object type.

So, now in the group if you wanted to say ... change the second checkbox checked to true, you would need to do this:

      const checkboxes = ref([]);

// ...

// ✅ It got unwrapped!
checkboxes.value[1].checked = true;

// ❌ Crashes because `checked` is not wrapped anymore when accessed from `checkboxes.value`.
checkboxes.value[1].checked.value = true;

    

So you can see where it could get confusing, but another use case I personally use is that reactive makes certain things way easier. For example, when listing feature cards of a product where some of them have conditions on whether they should be rendered or not:

So there I'm actually taking advantage of the reactive behavior to unwrap computed properties to make iteration easier. If we didn't use reactive, then we'd have to use .value when checking the disabled property in the template, which could be doubly confusing because you know that templates automatically unwrap ref and computed. But the key difference between reactive unwrapping and template unwrapping is that template unwrapping is shallow (it only does it for one level deep) while reactive does it recursively no matter the depth.

I can also freely mix in a ref disabled property with non-reactive versions of it, so the unwrapping effectively normalizes the properties for you.

Cloning Complications

One thing that frustrates me with ref and reactive is their behavior of wrapping every nested object in Proxy instances. While this can be useful as you've seen in the previous caveat, it can easily backfire and completely block a feature we now have in the JS language, like cloning objects!

If you're using lodash or klona or any other library to clone objects, they'll just work with reactive values and you won't notice a thing. But if you decide to get cute like me and save some kilobytes by using the widely available built-in structuredClone, it will crash with a weird error:

      import { ref } from 'vue'

const object = ref({ count: 0 });

// ❌ Failed to execute 'structuredClone' ... <Object> could not be cloned.
structuredClone(object.value)

    

Meanwhile, if you swap out ref with shallowRef, it works perfectly! The reason is that structuredClone doesn't work with Proxies, which means it won't work with reactive or with ref if the value contains nested objects.

This is a huge bummer for library authors who may use immutable data patterns to avoid reactivity leaks. Personally, I do have cloning logic in most of my libraries, and the only solution here is to use either a third-party library as a dependency or iterate manually and recursively and copy the object properties. In other words, implementing clone yourself.

You might think "You could use shallowRef since it works well, right?" Well, yes and no. If I'm writing an application in production, I could use shallowRef as a rule and avoid using ref and reactive completely. But at the same time, I'm working on a team with other developers who will use the most common way to create reactive values. Not only that, but as a library author, I have no way of preventing developers from using ref or reactive when passing values to my API, nor would it be a good idea to do so even if it were possible.

You may think this caveat is limited to cloning use cases, but in case you don't know how we got structuredClone in the first place: it was always something the browser did when needing to transfer objects across threads with worker message events or when working with IndexedDB.

Here's an example to illustrate both APIs failing. Notice the error is the same as the one with structuredClone, as it's used by the browser internally for those APIs.

As someone who builds high-performance applications that require offline data caching and sometimes worker logic, I need to be careful not to send anything reactive to those APIs because it would fail due to this caveat.

There aren't viable workarounds to this issue. You need to deeply "de-proxify" all nested properties before using any of those. Vue does ship with unref and toRaw, but neither handles the deep proxies created by Vue in the first place, plus that's not what those functions are for.

So the only way here is to "clone" the reactive object, either with a custom implementation or using utility libraries like I mentioned. In other words, walk the object tree and copy the value manually and do that recursively. I saw some workarounds that use toRaw recursively, but that's just doing a "clone" to do a "clone." You've already recursively iterated over all the properties, so you might as well just copy the value and be done with it.

There's another workaround that I use from time to time, an old cloning technique:

      function toRawDeep(obj) {
  return JSON.parse(JSON.stringify(obj));
}

    

It doesn't work if your object contains functions or non-serializable values, but it can be a quick workaround and a useful function to have in your toolkit. Otherwise, use a library.

Conclusion

These caveats might sound like a big deal, but honestly? They're not. Once you know about them, you can either take advantage of them (like with the nested refs unwrapping) or work around minor inconveniences (like with the cloning limitations).

What I love about Vue's reactivity system is that these edge cases are actually predictable once you understand the underlying mechanics. The automatic unwrapping isn't random; it follows clear rules. The Proxy limitations aren't Vue's fault; they're just how the web platform works. If they ever decide to make proxies transferable with structuredClone, then it will just go away on its own.

The Vue team made deliberate DX decisions that work beautifully most of the time for most of the developers. That remaining 0.1%? Well, now you know how to handle it.

Vue's reactivity remains one of the most elegant solutions in the frontend space. These caveats don't diminish that; they just make you a more informed developer. And in my book, that's always worth it.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)