Vue's reactivity is a trap
jkonieczny
Posted on March 11, 2024
Vue's reactivity is awesome, but it has a "little" drawback: it messes up with the references. Things that should work, that TypeScript says is fine, suddenly isn't fine.
I won't even talk about nested Ref
s with enforced typing, it's complete chaos you simply can't currently manage without becoming a master in parkour (or just cast as any
, but it's not a good way).
Even in daily use, the reactivity might cause a lot of confusing problems, if you don't know how it works underneath.
A simple array
Look at this code:
let notifications = [] as Notification[];
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
notifications.push(notification);
function removeNotification() {
notifications = notifications
.filter((inList) => inList != notification);
}
if (autoclose > 0) {
setTimeout(removeNotification, autoclose);
}
return removeNotification;
}
It's fine, right? If autoclose
is no zero, it will remove the notification from the list automatically. We can also call the returned function to close it manually. Clear and nice, the removeNotification
will work fine even if called twice, it will remove only the thing that is exactly the element we pushed to the array.
Okay, but it's not reactive. Now look at this:
const notifications = ref<Notification[]>([]);
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
notifications.value.push(notification);
function removeNotification() {
notifications.value = notifications.value
.filter((inList) => inList != notification);
}
if (autoclose > 0) {
setTimeout(removeNotification, autoclose);
}
return removeNotification;
}
It's the same, so it should work, right? We get the array iterate through the items and filter out the item that is the same as we added. But it doesn't. It is because the notification
object we receive as an argument is most likely a plain JS object, while the item in the array is a Proxy
.
How to deal with that?
Use Vue's API
If you don't want to modify the object for some reason, you can just get the actual item in the array with toRaw
(explained here). Then the function should look like this:
function removeNotification() {
notifications.value = notifications.value
.filter(i => toRaw(i) != notification);
}
In short, the function toRaw
returns the actual instance under Proxy, that way you can simply compare the instances and it should be fine.
However, this might be problematic, later you will know why.
Just use an ID/Symbol
Simplest and most obvious solution. Add into Notification
an ID or a UUID. You don't want to generate one in a code that invokes the notification every time to keep it simple like showNotification({ title: "Done!", type: "success" })
, so put it here:
type StoredNotification = Notification & {
__uuid: string;
};
const notifications = ref<StoredNotification[]>([]);
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
const stored = {
...notification,
__uuid: uuidv4(),
}
notifications.value.push(stored);
function removeNotification() {
notifications.value = notifications.value
.filter((inList) => inList.__uuid != stored.__uuid);
}
// ...
}
Since JS runtime environment is single threaded, we don't send it anywhere, we can just make a counter and generated id like:
let _notificationId = 1;
function getNextNotificationId() {
const id = _notificationId++;
return `n-${id++}`;
}
// ...
const stored = {
...notification,
__uuid: getNextNotificationId(),
}
Really, as long as this __uuid
won't be sent anywhere, this one should be fine, at least for the first 2⁵³ times it was called. Or just use date timestamp with an incrementing value.
If you are worried about the 2⁵³ being the maximum safe integer value, you can do this:
function getNextNotificationId() {
const id = _notificationId++;
if (_notificationId > 1000000) _notificationId = 1;
return `n-${new Date().getTime()}-${id++}`;
}
It solves the problem here, but it's not what I wanted to write about.
Use shallow reactivity
Why use deep reactivity, if it's not needed? Seriously, I am aware, that it's simple, the performance is good, but... Why use deep reactivity, when it's not even utilized?
Nothing in the given object will have to change. It's most likely a definition for a notification to show, with some labels, maybe with some actions (functions), but it won't change anything inside. Just replace ref
with shallowRef
, and voila!
const notifications = shallowRef<Notification[]>([]);
Now notifications.value
will return the source array. However, it's easy to forget that that way the array won't be reactive itself, we can't call .push
, because it won't trigger any effects. That's why if we just replace ref
with shallowRef
it will look like it's updating only when items are removed from the array because that's when we are reassigning the array with the new instance. We need to replace this:
notifications.value.push(stored);
with this:
notifications.value = [...notifications.value, stored];
That way notifications.value
will return a plain array of plain objects we can safely compare with ==
.
Let's summarize some information and explain some things:
Plain JS object – it's a simple raw JS object, without any wrappers,
console.log
will print just{ title: 'foo' }
, no magic attached.ref
andshallowRef
instances directly print an object of a class namedRefImpl
with a field (or rather getter).value
and some other private fields, we shouldn't deal with.ref
's.value
returns the same thing that returnsreactive
, aProxy
that mimics the given value, therefore it will printProxy(Object) {title: 'foo'}
. Every non-primitive nested field will also be aProxy
.shallowRef
's.value
returns the plain JS object. Just like that, only the.value
is reactive here (normally, I'll explain later), and no nested fields are.
We can summarize it with this:
plain: {title: 'foo'}
deep: RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: Proxy(Object)}
deepValue: Proxy(Object) {title: 'foo'}
shallow: RefImpl {__v_isShallow: true, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: {…}}
shallowValue: {title: 'foo'}
Now look at this code:
const raw = { label: "foo" };
const deep = ref(raw);
const shallow = shallowRef(raw);
const wrappedShallow = shallowRef(deep);
const list = ref([deep.value]);
const res = {
compareRawToOriginal: toRaw(list.value[0]) == raw,
compareToRef: list.value[0] == deep.value,
compareRawToRef: toRaw(list.value[0]) == deep.value,
compareToShallow: toRaw(list.value[0]) == shallow.value,
compareToRawedRef: toRaw(list.value[0]) == toRaw(deep.value),
compareToShallowRef: list.value[0] == shallow,
compareToWrappedShallow: deep == wrappedShallow,
}
And the result:
{
"compareRawToOriginal": true,
"compareToRef": true,
"compareRawToRef": false,
"compareToShallow": true,
"compareToRawedRef": true,
"compareToShallowRef": false,
"compareToWrappedShallowRef": true
}
Explanation:
compareOriginal (
toRaw(list.value[0]) == raw
):toRaw(l.value[0])
will return the same thingraw
is: a plain JS object instance. It confirms what we expect.compareToRef (
list.value[0] == deep.value
):deep.value
is a Proxy, the same as the array's proxy will use, no need to create another wrapper, right? Also, there is another mechanism working herecompareRawToRef (
toRaw(list.value[0]) == deep.value
): we are comparing a "rawed" object to Proxy. Earlier we proved thattoRaw(l.value[0])
is the same asraw
, therefore it can't be a Proxy.compareToShallow (
toRaw(list.value[0]) == shallow.value
): here however we are comparingraw
(returned throughtoRaw
) to value stored byshallowRef
, which isn't reactive, so Vue doesn't return any Proxy here, just the plain object, which means theraw
. No magic here, as we expected.compareToRawedRef (
toRaw(list.value[0]) == toRaw(deep.value)
): But again, if we comparetoRaw(l.value[0])
withtoRaw(deep.value)
, it will be the same raw object. Anyway, we proven earlier thatl.value[0]
is the same thing asdeep.value
. This one is even marked as an error in TypeScriptcompareToShallowRef (
list.value[0] == shallow
): obviously false, sinceshallowRef
's Proxy won't be the same asref
's Proxy.compareToWrappedShallowRef (
deep == wrappedShallow
): this one is... a WTF. For some reason,shallowRef
if is given aref
, it just returns thatref
(here). It makes perfect sense if both source and expected refs are the same type (shallow or deep), but here... it's just... weird.
Summarizing:
deep.value
==list[0].value
(areactive
inside)shallow.value
==raw
(plain object, no magic)toRef(deep.value)
==toRef(list[0].value)
==raw
==shallow.value
(getting plain object)wrappedShallow
==deep
, thereforewrappedShallow.value
==deep.value
(reusingreactive
created for the target)
Now with the second item, created from a shallowRef
's value or raw
directly:
const list = ref([shallow.value]);
{
"compareRawToOriginal": true,
"compareToRef": true,
"compareRawToRef": false,
"compareToShallow": true,
"compareToRawedRef": true,
"compareToShallowRef": false
}
Well, no surprises, let's focus on the most interesting ones:
compareToRef (
list.value[0] == deep.value
): we are comparingProxy
returned by the list withref
's.value
created from the same source. why is it true? How can it be? Vue internally uses a WeakMap to store references to allreactive
s, so when it creates one, it can check if one is already created and reuses it. That's how two separateref
created from the same source will see each other changes. Thoseref
s will have the same.value
.compareRawToRef (
toRaw(list.value[0]) == deep.value
): we again compare a plain object withRefImpl
.compareToShallowRef (
list.value[0] == shallow
): even if the item was created from the shallowRef's value, the list is deep reactive and it returns deep reactiveRefImpl
, which all fields are reactive. The left side here contains Proxy, right side is an instance.
So what about it?
Even if we replace ref
with shallowRef
for the list, if the value given as argument was reactive, the list will contain a reactive element, even if it's not deep reactive.
const notification = ref({ title: "foo" });
showNotification(notification.value);
The value added to the array will be Proxy
, not the { title: 'foo' }
. Gladly, the ==
will compare it correctly, since the object returned by .value
will change as well. However, if we do toRaw
on one side, ==
won't compare the objects correctly.
Conclusion
While deep reactivity in Vue is awesome, it's also full of traps we must be careful of. We need to remember that when working with deep reactive objects, we constantly work with proxies, not the actual JS objects.
Try to avoid at all costs comparing reactive object instances with ==
, if you really have to, make sure you do it correctly, maybe using toRaw
on both sides might be required. Instead, try to add unique identifiers, ID, UUID, or use an existing unique per-item primitive values you can safely compare. If it's an item from the database, it's most likely it has a unique ID or UUID (and probably date of modification, if that matters too).
Don't ever think about using Ref
's as other Ref's initial value. Use its .value
, or get the right value by toValue
or toRaw
, depends on how hard you want your code to be debuggable.
Use shallow reactivity, when it's convenient. Or rather: use deep reactivity when it's needed. In most cases, you don't need deep reactivity. Sure, it's nice to be able to write just v-model="form.name"
, instead of rewriting the whole object, but do you need a read-only list received from the backend to be reactive?
With a huge array, I've managed to get twice better performance when rendering, while it's not impressive difference between 2ms and 4ms, the difference between 200ms and 400ms is noticeable. The more complicated the data structure is (nested objects and arrays), the bigger the difference will be.
Vue's reactivity typing is a mess. As long as you do simple stuff, it's fine. However, when you start doing weird stuff, you'll need to do even weirder stuff to deal with the consequences, but you probably, just go too far with doing weird stuff and you should just take a few steps backward. I won't even dive into storing Ref
s inside other Ref
s, this is even more messed up.
TLDR;
Don't nest
Ref
s. Use the values (myRef.value
) instead, but remember that it might contain areactive
, even if you get it fromshallowRef
If you need (for some reason) to compare object instances with
==
, usetoRaw
to make sure you are comparing plain JS objects, however, it's better to compare primitive unique values, like IDs or UUIDs
But this is just an article with some information, if you know what you are doing, feel free to experiment.
Posted on March 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.