One of the things that I learnt the hard way in my time working with Vue.js apps is Reactivity problems. One might say that Reactivity in Vue.js is simple and intuitive. The reality it actually is, it’s reactivity out of the box per se. But good luck debugging it if you have to. It’s rare, but when it happens, good luck with that!
So, I decided that my first article would be a remainder, for me beforemost, and for anyone who reads, about some tips about Reactivity problems, and how to avoid them with some immutability concepts and cloning strategies.
Undesired Side Effects
Okay, now, take a look at this code:
<script setup>
const users = reactive([
{ id: 1, name: 'Charlie', age: 30 },
{ id: 2, name: 'Ali', age: 25 },
{ id: 3, name: 'Maher', age: 35 }
])
const sortedUsers = computed(() => {
return users.sort((a, b) => a.name.localeCompare(b.name)) // The issue
})
return { users, sortedUsers }
</script>
Looks innocent, right? Wrong. The .sort()
method mutates the original array. So when the computed property runs, it’s not just returning a sorted version - it’s actually rearranging the original users
array itself.
The thing is it’s not just .sort()
that has this behavior. Array has a bunch of mutating
other methods, such as .reverse()
, .push()
, .splice()
, .fill()
. These methods will modify your original data whether you like it or not!
In regular JavaScript, this might be fine. But in Vue’s reactive world, these mutations can create a cascade of unintended effects. When you mutate reactive data, you’re not just changing a variable - you’re triggering Vue’s entire reactivity system, potentially causing components to re-render, computed properties to recalculate, and watchers to fire.
Now, let’s see the correct way to do this:
// ...
const sortedUsers = computed(() => {
return [...users].sort((a, b) => a.name.localeCompare(b.name))
})
// ...
Key difference? Immutablity! Just cloning the original Array before sorting it. This simple change prevents so many headaches. The original data stays untouched, and you get predictable behavior.
N.B. Javascript has now new methods that don’t mutate the array: toSorted()
, toReversed()
, and toSpliced()
.
Shallow or Deep?
Here’s the thing, you’d probably know that, we have two types of cloning, shallow cloning, cloning the actual values, it works with simple data types and objects’ first level of nesting. The other one is deep cloning and this clones all nested levels of an object. Here’s a clarification:
const users = [
{ id: 1, name: 'Ali', profile: { age: 25, city: 'NYC' } }
]
// Shallow immutability
const newUsers = [...users]
newUsers[0].name = 'Maher' // This is safe
newUsers[0].profile.age = 30 // This mutates the original! (Nested Object)
console.log(users[0].profile.age) // 30 - original was changed!
So if we have a complex object with nested levels, we need to do a deep clone:
// Deep immutability with structuredClone
const deepClone = structuredClone(users)
deepClone[0].profile.age = 30 // Original stays untouched
// Or with spread (for simple nesting)
const manualDeepClone = users.map(user => ({
...user,
profile: { ...user.profile }
}))
So when to clone deep and when shallow? It depends! Most of the time, you need deep immutability when working with complex reactive data in Vue.
The Plot Twist: Cloning Kills Reactivity
Here’s the thing that took me way too long to figure out: cloning reactive data strips away its reactivity.
//let's say we have a reactive object like this
const reactiveUsers = reactive([
{ id: 1, name: 'Ali' }
])
// All these lose reactivity
const clone1 = [...reactiveUsers] // Not reactive
const clone2 = JSON.parse(JSON.stringify(reactiveUsers)) // Not reactive
const clone3 = structuredClone(reactiveUsers) // Not reactive
// The clones are just plain JavaScript objects/arrays
clone1.push({ id: 2, name: 'Maher' }) // Won't trigger Vue updates!
This makes sense when you think about it. Vue’s reactivity comes from Proxy wrappers around your data. When you clone, you’re creating new objects without those wrappers.
Solution: Clone, Then Wrap
If you need a reactive clone, you have to explicitly wrap it again:
import { reactive, toRaw } from 'vue'
const reactiveUsers = reactive([{ id: 1, name: 'Ali' }])
// Step 1: Strip reactivity with toRaw()
const rawUsers = toRaw(reactiveUsers)
// Step 2: Clone the raw data
const clonedData = structuredClone(rawUsers)
// Step 3: Make it reactive again
const reactiveClone = reactive(clonedData)
// Now reactiveClone is properly reactive
reactiveClone.push({ id: 2, name: 'Maher' }) // Triggers updates
But here’s the thing, don’t try to clone a reactive object directly! First you have to strip the reactivity from it then clone, or else you’d get this error from Vue: “Failed to execute ‘structuredClone’ on ‘Window’: #<Object>
could not be cloned.”
<script setup>
import { reactive } from 'vue'
const msg = reactive({
name: 'ali',
id: 1
})
const clonedMsg = structuredClone(msg) //Errors
</script>
Here’s some Real-World Examples
So when to look out for this?
Never Trust Props
<script setup>
// Define props
const props = defineProps({
userData: Object
})
// Bad Practice: This mutates props directly, which Vue warns against
// const localData = reactive(props.userData)
// Good Practice: Clone the prop before making it reactive
const localData = reactive(structuredClone(props.userData))
// You can now safely mutate `localData` without affecting the parent
const updateUser = (changes) => {
Object.assign(localData, changes)
}
</script>
Defensive Form Reset
const useForm = (initialData) => {
// Keep pristine copy for reset
const pristineData = structuredClone(initialData)
// Working copy that can be mutated
const formData = reactive(structuredClone(initialData))
const reset = () => {
// Clear and repopulate to maintain reactivity
Object.keys(formData).forEach(key => delete formData[key])
Object.assign(formData, structuredClone(pristineData))
}
const isDirty = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(pristineData)
})
return { formData, reset, isDirty }
}
Watchout for Array operations
Just as the example I showed above. Be careful when dealing with destructive array methods.
const useSafeArrayOps = (reactiveArray) => {
// Safe sorting that doesn't mutate original
const sortBy = (field) => {
const sorted = [...toRaw(reactiveArray)].sort((a, b) =>
a[field].localeCompare(b[field])
)
return reactive(sorted)
}
// Safe filtering
const filterBy = (predicate) => {
const filtered = toRaw(reactiveArray).filter(predicate)
return reactive(filtered)
}
return { sortBy, filterBy }
}
My Debugging Routine
Now, When I run into weird reactivity issues, I always ask myself these questions:
-
Am I mutating reactive data directly? Check for
.sort()
,.reverse()
, direct property assignment on nested objects. -
Am I sharing references between components? Two components pointing to the same reactive object will affect each other.
-
Did I clone something and expect it to stay reactive? Cloning strips reactivity - need to wrap it again.
-
Am I mutating props? Always clone props before making them reactive in child components.
-
Are my immutable operations actually immutable? Shallow cloning with nested objects can still cause mutations.
Final Thoughts
Vue’s reactivity is genuinely amazing when it works. But when it doesn’t, it can be so frustrating. The key insight that saved my sanity is understanding that I’m writing Javascript afterall.
Inspiration
Finally, I’d like to thank Abdelrahman Awad for his incredible work as this article is inspired by some of his videos.
Here are a few of his videos that particularly stood out: