Vue's reactivity is a trap
Tags: vue, webdev, programming, javascript
Published: 2024-03-11 18:36:15

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:

```typescript
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:

```typescript
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](https://vuejs.org/api/reactivity-advanced.html#toraw)). Then the function should look like this:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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!

```typescript
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:

```typescript
notifications.value.push(stored);
```

with this:

```typescript
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` 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:

```typescript
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:

```typescript
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:

```typescript
{
  "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 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](https://github.com/vuejs/core/blob/main/packages/reactivity/src/ref.ts#L149)). 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:

```typescript
const list = ref([shallow.value]);
```

```typescript
{
  "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 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 `reactive`s, 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 `ref`s 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.

```typescript
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 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.