Vue's reactivity is a trap

razi91

jkonieczny

Posted on March 11, 2024

Vue's reactivity is a trap

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 Refs 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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(),
}
Enter fullscreen mode Exit fullscreen mode

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++}`;
}
Enter fullscreen mode Exit fullscreen mode

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[]>([]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

with this:

notifications.value = [...notifications.value, stored];
Enter fullscreen mode Exit fullscreen mode

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 and shallowRef instances directly print an object of a class named RefImpl 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 returns reactive, a Proxy that mimics the given value, therefore it will print Proxy(Object) {title: 'foo'}. Every non-primitive nested field will also be a Proxy.

  • 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'}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

And the result:

{
  "compareRawToOriginal": true,
  "compareToRef": true,
  "compareRawToRef": false,
  "compareToShallow": true,
  "compareToRawedRef": true,
  "compareToShallowRef": false,
  "compareToWrappedShallowRef": true
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • compareOriginal (toRaw(list.value[0]) == raw): toRaw(l.value[0]) will return the same thing raw 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 here

  • compareRawToRef (toRaw(list.value[0]) == deep.value): we are comparing a "rawed" object to Proxy. Earlier we proved that toRaw(l.value[0]) is the same as raw, therefore it can't be a Proxy.

  • compareToShallow (toRaw(list.value[0]) == shallow.value): here however we are comparing raw (returned through toRaw) to value stored by shallowRef, which isn't reactive, so Vue doesn't return any Proxy here, just the plain object, which means the raw . No magic here, as we expected.

  • compareToRawedRef (toRaw(list.value[0]) == toRaw(deep.value)): But again, if we compare toRaw(l.value[0]) with toRaw(deep.value), it will be the same raw object. Anyway, we proven earlier that l.value[0] is the same thing as deep.value . This one is even marked as an error in TypeScript

  • compareToShallowRef (list.value[0] == shallow): obviously false, since shallowRef's Proxy won't be the same as ref's Proxy.

  • compareToWrappedShallowRef (deep == wrappedShallow): this one is... a WTF. For some reason, shallowRef if is given a ref, it just returns that ref (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 (a reactive inside)

  • shallow.value == raw (plain object, no magic)

  • toRef(deep.value) == toRef(list[0].value) == raw == shallow.value (getting plain object)

  • wrappedShallow == deep , therefore wrappedShallow.value == deep.value (reusing reactive created for the target)

Now with the second item, created from a shallowRef's value or raw directly:

const list = ref([shallow.value]);
Enter fullscreen mode Exit fullscreen mode
{
  "compareRawToOriginal": true,
  "compareToRef": true,
  "compareRawToRef": false,
  "compareToShallow": true,
  "compareToRawedRef": true,
  "compareToShallowRef": false
}
Enter fullscreen mode Exit fullscreen mode

Well, no surprises, let's focus on the most interesting ones:

  • compareToRef (list.value[0] == deep.value): we are comparing Proxy returned by the list with ref's .value created from the same source. why is it true? How can it be? Vue internally uses a WeakMap to store references to all reactives, so when it creates one, it can check if one is already created and reuses it. That's how two separate ref created from the same source will see each other changes. Those refs will have the same .value.

  • compareRawToRef (toRaw(list.value[0]) == deep.value): we again compare a plain object with RefImpl.

  • 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 reactive RefImpl, 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);
Enter fullscreen mode Exit fullscreen mode

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 Refs inside other Refs, this is even more messed up.

TLDR;

  • Don't nest Refs. Use the values (myRef.value) instead, but remember that it might contain a reactive, even if you get it from shallowRef

  • If you need (for some reason) to compare object instances with ==, use toRaw 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.

💖 💪 🙅 🚩
razi91
jkonieczny

Posted on March 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Vue's reactivity is a trap
vue Vue's reactivity is a trap

March 11, 2024

Vue.js Basics Part 9 | Slots
vue Vue.js Basics Part 9 | Slots

September 8, 2022