Skip to content

Common problems with reactivity #849

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
skirtles-code opened this issue Feb 7, 2021 · 21 comments
Open

Common problems with reactivity #849

skirtles-code opened this issue Feb 7, 2021 · 21 comments
Labels
discussion Topics to discuss that don't have clear action items yet

Comments

@skirtles-code
Copy link
Contributor

skirtles-code commented Feb 7, 2021

This is an umbrella issue, intended to gather examples of common problems, mistakes and misunderstandings when using the proxy-based reactivity system. The objective is to gather source material that might be used to add a new page to the documentation, or enhance the existing pages.

The idea is that it will be something similar to the Reactivity Caveats for Vue 2. However, I'm keen for the scope to be quite broad at this stage and we can decide what is actually worth documenting later. Anything related to reactivity that might catch out a beginner is in scope for now, even if it is already documented. That includes examples of things that do work but where it isn't necessarily obvious why they work. These can cause just as much confusion as scenarios that don't work correctly.

While most reactivity problems can be reduced down to 'you need to go through the proxy', I think there are plenty of cases where it isn't immediately obvious how that applies, especially for newcomers. My hope is that documenting some of these edge cases will save people from having to learn about them the hard way, while also giving them a deeper understanding of how reactivity works.

I've outlined various example below. If anyone has more to add, please do. Anything else that might help us to document the problems I've already described would also be welcome.

@skirtles-code skirtles-code added the discussion Topics to discuss that don't have clear action items yet label Feb 7, 2021
@skirtles-code
Copy link
Contributor Author

Full example: https://jsfiddle.net/skirtle/wcdfe1ub/

Key section:

async created () {
  const list = await loadList()
    
  this.items = list
    
  const list2 = await loadList2()
    
  list.push(...list2)
}

This uses the options API with no explicit mention of reactive or ref. The final line needs to be this.items.push(...list2) to go through the proxy. It is not immediately obvious that list and this.items are not equivalent after the line this.items = list. This is contrary to how things work in 'normal' JavaScript.

In Vue 2 this would have worked fine.

A similar example inside a Vuex action would be even more fiddly, with commit and state complicating things further. A user may attempt to put an extra call to commit at the end but that won't actually help.

@skirtles-code
Copy link
Contributor Author

skirtles-code commented Feb 8, 2021

There's a common question that gets asked: What is the difference between reactive and ref and when should I use one instead of the other?

Usually the question is in the context of objects rather than primitive values.

One way of explaining it is with analogy to const and let.

It's also potentially worth explaining that ref uses reactive on its value, so ref is just an extra wrapper around reactive to allow for reassignment.


Here's an example based on a question on the forum. There are various problems with it but I think it's interesting to consider why it has been written this way and how we should address that in the docs:

setup () {
  const data = reactive({ text: 'Loading' })
  return { data }
},

async mounted () {
  const resp = await this.$http.get(...)
  this.data = resp.data
}

@skirtles-code
Copy link
Contributor Author

Reassigning a local variable does nothing from a reactivity perspective:

setup () {
  let myValue = ref(false)

  const update () {
    myValue = ref(true)
    // or even:
    // myValue = true
  }

  return {
    myValue,
    update
  }
}

While the example above uses ref, there are similar examples using reactive and or even a plain value.

The misunderstanding seems to come from believing that the myValue variable itself is reactive, rather than its value. This reasoning makes some sense as data properties seem to work that way if you aren't familiar with the inner workings.

The confusion can sometimes be tracked back to the automatic wrapping/unwrapping, which can make setup appear inconsistent with code elsewhere:

<button @click="myValue = true">Update</button>

While this event listener appears to be doing the same thing as the earlier update function, it benefits from the automatic wrapping.

Beyond the problem that reassigning a local variable can't be tracked, there is the further problem that the value returned by setup is not updated because it still refers to the original ref. This can cause even more confusion. Code inside the setup function will see the correct value but anything outside setup, such as the template, will still see the original value. Even if something else triggers a re-render it won't use that new value.

@skirtles-code
Copy link
Contributor Author

While it is already documented, destructuring is a common source of problems. This isn't a problem with destructuring itself, it's just the most common way to read a property outside of an effect:

setup({ text }) {
  // text is not reactive
}

Or without destructuring:

setup(props) {
  const text = props.text
  // text is not reactive
}

Arguably, the underlying problem here is that setup itself is not wrapped in an effect. A lot of Vue users don't have a clear understanding of what an effect is and how it impacts reactivity. The lifecycle hooks are similar but they are less likely to be used in this way.

Something similar can happen with data:

data () {
  return {
    text: this.propValue
  }
}

While this is perfectly valid for defining an initial value for text, some users expect the value of text to track the prop. As with the setup example, the solution is to use computed.

@skirtles-code
Copy link
Contributor Author

skirtles-code commented Feb 8, 2021

Update: props does now include all props, even if they aren't passed. The problem described here does still exist, but it won't happen with props, which was the most common case.


This is already documented but toRefs will only work for properties that currently exist. toRef, on the other hand, can cope with missing properties.

In particular, the props object in setup won't include props that weren't passed, so toRefs won't include a ref for them. This can result in an error trying to access .value of undefined but only if the value is accessed that way. If the value is accessed via automatic unwrapping it won't cause an error, reactivity will just fail to work:

<template>
  <div>{{ content || 'Loading' }}</div>
</template>

<script>
import { toRef } from 'vue'

export default {
  props: ['text'],

  setup (props) {
    // `content` will be undefined if the `text` prop is missing.
    // The initial render will be fine but it won't be reactive.
    const content = toRefs(props).text

    return {
      content
    }
  }
}
</script>

The example above is sufficiently simple that it can be easily fixed by using the prop directly in the template. It is purely intended to illustrate how toRefs can cause a silent reactivity failure.

@skirtles-code
Copy link
Contributor Author

Working with third-party libraries that aren't designed to be compatible with Vue can be difficult and has changed from Vue 2 to Vue 3. Similar problems can occur when users attempt to use their own objects if those objects aren't 'plain'.

When an object is provided by a third-party library there often isn't any way to tell the library to make changes via the proxy. In Vue 2, the getter/setter approach still worked so long as the library exposed all the relevant properties rather than hiding them as local variables within closures.

So, an object like this wouldn't be compatible with reactivity in Vue 2 or Vue 3:

let value = 1

const obj = {
  getValue () {
    return value
  },

  setValue (newValue) {
    value = newValue
  }
}

Whereas something like this could work in both:

const obj = {
  value: 1,

  getValue () {
    return this.value
  },

  setValue (newValue) {
    this.value = newValue
  }
}

It isn't immediately obvious that this second example would be compatible with proxy-based reactivity, as it appears to be accessing this.value without going through the proxy. So long as all calls to getValue and setValue are made via the proxy it will work, though the magic of Reflect is likely not widely understood. In Vue 2, it would work even if the object is accessed directly, e.g. by other code within the library.

The proxy-based reactivity can be a massive performance boost with some third-party libraries, so it isn't all bad news from that perspective. In Vue 2 there were cases where objects had to be held in non-reactive properties just to avoid the performance drag of rewriting all the nested properties.

In practice, using 'plain' objects has always been recommended, so trying to graft reactivity onto a third-party library has always been a bit dubious.

@skirtles-code
Copy link
Contributor Author

There are two key principles that are sometimes overlooked, or deliberately ignored because they aren't properly understood:

  1. Don't mutate any objects/arrays returned by computed.
  2. Don't mutate objects/arrays in a component unless they are owned by that component (one-way data flow).

These can be applied to working with Vuex but I'll stick to components here.

One reason why mutating objects in a child is problematic is because the object passed in via a prop may not be reactive. In that case, modifying it directly won't necessarily trigger the required updates.

As for why such an object would be unreactive, one common reason would be that the object is created by a computed property. There's a common misunderstanding around the reactivity of computed properties. If a computed property returns an object, no reactivity will be added to that object. There doesn't need to be because it shouldn't be mutated. Only the computed property itself needs to be tracked as that should be the only thing changing its value. Nested objects could, in theory, change if they are pulled in from elsewhere but that isn't the computed property's responsibility.

Similarly, props don't add any reactivity if their values are objects/arrays. Only the property itself is reactive.

When these values are logged they will be plain values, rather than proxies. This can confuse some users who perceive this to be a sign that reactivity is broken. Worse, the confusion can be heightened because logging other values would show proxies, if those values happen to have been made reactive by some other upstream process.

@skirtles-code
Copy link
Contributor Author

The documentation is already clear about the need to always go through the proxy. Further, it outlines the potential problems that can be caused by equality checking, because a proxy does not === the original object.

Some interesting edges cases occur with the array methods includes, indexOf and lastIndexOf, which internally use ===. For example:

const obj = {}
const react = Vue.reactive(obj)
const arr = Vue.reactive([obj])

console.log(arr.indexOf(obj)) // Logs 0
console.log(arr.indexOf(react)) // Logs 0

What gives? If obj !== react then how come they both return 0?

There is some magic at work here:

https://github.com/vuejs/vue-next/blob/dfd31c363631c20f788e012881516b07ce622618/packages/reactivity/src/baseHandlers.ts#L56

@skirtles-code
Copy link
Contributor Author

skirtles-code commented Feb 8, 2021

Update: Things have changed since this comment was written. It is still potentially useful as a checklist of things to consider, but I wouldn't rely on it if you're trying to learn how unwrapping works.


The automatic wrapping/unwrapping of a ref is a rich source of potential confusion. While I'm fairly clear about how it works now, I personally went through several weeks of trial and error when it was first introduced.

The biggest myth seems to be that the unwrapping is performed in the template. I can't find any evidence of that. The template relies on the same unwrapping mechanisms that you'd get if you accessed the same properties using this.propertyName directly.

I'm going to use the term ref to refer to both ref and computed.

A quick summary:

  1. A ref inside a reactive is automatically wrapped/unwrapped.
  2. A ref inside a readonly is unwrapped (wrapping doesn't apply because it's read-only).
  3. A ref inside a shallowReactive or shallowReadonly is not wrapped/unwrapped, even for root-level properties.
  4. Nesting a ref inside another ref does not seem to apply any wrapping/unwrapping in most cases, though when I tested it myself it seemed a bit unpredictable and error-prone so I'm unclear whether this is even supported.
  5. If the object returned by setup is not already reactive, it will be wrapped in a proxy that shallowly unwraps ref properties.
  6. provide/inject does not do any unwrapping.
  7. computed properties do not do any unwrapping, though they usually don't need to for the same reason that a template doesn't need to.

Add to that the following:

  1. The object returned from data is wrapped in reactive to create $data. So data properties inherit unwrapping behaviour from reactive.
  2. The props object passed to setup, which is also accessible via the $props property, is created using shallowReactive, so it does not unwrap refs. However, in most cases a ref will be unwrapped in the parent before it makes it to the prop.

Once you've got all of that clear in your head, you might be able to figure out whether something is going to be automatically wrapped/unwrapped or not.

It is also worth noting that assigning a ref to a reactive property forms a live link between them (this is mentioned in the API reference):

const react = reactive({})
const obj = ref(7)

// The `count` property and `obj.value` will now be linked
react.count = obj

// This will update both `count` and `obj.value`
react.count = 8

This also applies to a computed ref, which may be read-only, causing the subsequent assignment to fail.

Assigning another ref will break the link with the original ref. The link can also be broken using delete to remove the property:

// Remove the link to the ref
delete react.count

// This will just assign the raw value without updating the previously linked ref
react.count = 8

@skirtles-code
Copy link
Contributor Author

Any effect created during setup will be automatically destroyed when the corresponding component instance is destroyed. This can occasionally catch people out if they create a computed property that is supposed to live beyond the lifespan of that specific component.

Here is a concrete example. Everything it does seems reasonable but it runs into this problem:

https://jsfiddle.net/skirtle/0cnek4sr/

In that specific case it is easily fixed, but only once you know what the problem is.

@skirtles-code
Copy link
Contributor Author

Proxy-based reactivity supports far more operations than Vue 2's reactivity system but it still doesn't cover everything. The following all work:

  • Basic reading/writing of properties.
  • Adding new properties to existing reactive objects via = assignment.
  • The in operator.
  • The delete operator.
  • Manipulating arrays, including direct access by index and manipulating length.
  • Sets and maps.
  • Object.getOwnPropertyNames.

However, hasOwnProperty and Object.getOwnPropertyDescriptor will not currently register a dependency on the relevant property. Adding a property using Object.defineProperty will not trigger any dependencies of that property.

@skirtles-code skirtles-code changed the title Common Problems using Vue 3 Reactivity Common problems with reactivity Feb 8, 2021
@LinusBorg
Copy link
Member

LinusBorg commented Mar 15, 2021

Saving a reactive proxy to chrome.storage is likely troublesome as well:

vuejs/core#3423

Snippet from the repo linked in that issue (in case it might get deleted)

// this.errorinfo is a reactive proxy containing: {errors: []}

chrome.storage.local.set({error_info: this.error_info}, function(){
  chrome.storage.local.get({error_info: {errors: []}}, function(obj){

    console.log('saved result', obj);
    // trying filter array of errors
    // throws `TypeError: obj.error_info.errors.filter is not a function`
    console.log(obj.error_info.errors.filter(function(el){
      el.message !== undefined
    }).length);
  });
});

toRaw() can help here.

@julie777
Copy link

1. Don't mutate any objects/arrays returned by `computed`

The statements above regarding this aren't always correct. They seem to be based on props. I have been doing some experiments with reactivity and I want to share a counter-example. I'm just going to provide the setup function.

setup(props) {
  let item = computed(() => store.items.find((it) => it.id = props.id)
  return {item}
}

Given that store has been imported and is reactive then the item returned from the computed function is a reactive object.
For my test store is just an object with and items field in a .js file defined with reactive.

I agree that the docs can still use some work.

@skirtles-code
Copy link
Contributor Author

There are some interesting cases involving watch.

Following on from earlier, properties accessed outside a reactive effect aren't tracked. The watch expression is a common source of this type of problem:

watch(a.b, ...)

Assuming the intention was to watch for changes to the b property of a, this won't work. Instead, it needs to be:

watch(() => a.b, ...)

I see this pretty frequently and it often proves difficult to explain why the function is necessary. It is sometimes perceived as Vue being deliberately awkward. I suspect many people regard the first example as being a bit like watching the string 'a.b', with the expectation that watch can somehow access that expression (a bit like directive expressions in templates).

When watching a ref, the wrapper can complicate matters:

const a = ref(7)

watch(() => a, ...)

Without the wrapper function, watching a ref directly work fine, as there's special handling for tracking the .value. Adding the wrapper function breaks it. It would need to be () => a.value, explicitly accessing the .value.


Another interesting aspect of watch is considering exactly which parts of the process track changes. e.g.

watch(() => a.b, () => {
  // ...
}, { deep: true })

Accessing a.b inside the first function will be tracked. But with deep: true there's another phase of tracking, using the value returned by that function. To reiterate, it is the value returned by the function that is watched deeply. Other properties read during the execution of that first function will only be read individually, not deeply.


Watching multiple values can provide some subtle edge cases too. Given the following object:

const obj = reactive({ a: 1, b: 2 })

What is the difference between these two different ways of watching a and b?

watch([() => obj.a, () => obj.b], v => {
  console.log(v)
})

watch(() => [obj.a, obj.b], v => {
  console.log(v)
})

In both cases, the same dependencies are tracked and the value of v seems to be an array containing a and b. So is the second version just an easier way to write the same thing? Not quite.

The difference occurs when the watched value is checked for changes. The first example yields a pair of values, and each will be checked to see whether it has changed. In the second example, a new array is being returned each time the function is called. Even if the values of a and b haven't changed, the new array won't === the previous array. So the second example will always trigger the callback, even if a and b haven't changed.

Running example: https://jsfiddle.net/skirtle/dq9kxfea/

@skirtles-code
Copy link
Contributor Author

I've encountered a few cases where triggerRef was used to try to work around a problem, but just caused more problems.

These two examples try to use triggerRef to give the reactivity system a kick when something changes, but it only works within the current component. Notice how the child component doesn't react to the changes:

This tends to happen in scenarios where some of the data can't be made reactive, either because of performance or because it comes from a third-party library that is incompatible with proxy-based reactivity. The idea is to try to manually trigger the reactivity system, but as shown in the examples above, it only works in a very limited way.

@akangaziz
Copy link

thanks. well summarized.

@kamran-12
Copy link

kamran-12 commented Feb 10, 2024

I am having the following problem. Among the same type of elements generated using v-for, only one is reactive. 3 page links in the nav should be displayed in 3 languages by user choice.

In the initial page load it works fine. Then click one language change link then again it works fine. But when I click a language change link another time only the last link's language changes when all of them should change language. What is causing this problem and how to fix it?

app.vue:

<template>
    <nav class="navbar">
        <NuxtLink class="pagelink" :key="page.slug" v-for="page in strings.pages" :href="'/' + page.slug">{{ page.name[lang] }}</NuxtLink>
        <Languages />
    </nav>
</template>
<script>
import Languages from "./components/languages.vue"
import languages from "../services/languages"
export default {
    name: "Navbar",
    data() {
        return {
            open: false,
            strings: {
                pages: [
                    {
                        slug: 'home',
                        name: { az: "Əsas", ru: "Главная", en: "Home" }
                    },
                    {
                        slug: 'about',
                        name: { az: "Haqqımızda", ru: "О нас", en: "About" }
                    },
                    {
                        slug: 'contact',
                        name: { az: "Əlaqə", ru: "Связаться", en: "Contact Us" }
                    }
                ]
            }
        }
    },
    computed: {
        lang() {
            return languages(this)
        }
    }
}
</script>

<style>
* {
    margin: 10px;
}
</style>

languages.vue:

<template>
    <div class="languages">
        <NuxtLink :to="route.path + '?hl=az'">AZ</NuxtLink>
        <NuxtLink :to="route.path + '?hl=ru'">RU</NuxtLink>
        <NuxtLink :to="route.path + '?hl=en'">EN</NuxtLink>
    </div>
</template>

<script>
export default {
    name: "Languages",
    setup() {
        const route = useRoute()
        return {
            route
        }
    }
}
</script>

<style scoped>
div,
div a {
    height: 40px;
    display: inline-block;
}

img {
    height: 60%;
    display: inline-flex;
    margin: 8px;
}
</style>

languages.js:

function languages(page) {
    let langCookie = useCookie("language")
    let language = langCookie.value
    if (page.$route.query.hl) {
        language = page.$route.query.hl
        langCookie.value = language
    }
    return language || 'az';
}

export default languages

I noticed that removing langCookie.value = language fixes the problem but setting cookies is necessary so just doing that is not option.

@noerxs
Copy link

noerxs commented Jun 21, 2024

`I'm suck with vue reactivity. I think it's some kind of joke.
I have 2 same method with different result.


setup() {
        const store = useStore();
        const route = useRoute();
        const query = route.query;
        const itemId = query.id;
        var dataUoM = reactive({
          uom: []
        });
        
        const getDataUoM = async() => {
            try{
              await store.dispatch('item/getUoM');
              const result = _.omit(store.getters['item/getItemData']);
              dataUoM.uom = result.data.map((obj) => {
                        return {
                            label : obj.uom_name,
                            value : obj.uom_id
                        }
              });
              return dataUoM;
            }catch(e){
              console.log(e);
            }; 
        };
        getDataUoM();
        console.log(dataUoM.uom); ----------> null
        return { 
            itemId,
            dataUoM,
        };
  },
  setup() {
        const store = useStore();
        const route = useRoute();
        const query = route.query;
        const userId = query.id;
        const dataGender = reactive({
            ognd: []
        });

        const getDataGender= async() => {
          try{
              await store.dispatch('user/getGender');
              const result_gender = _.omit(store.getters['user/getUserData']);
              dataGender.ognd =  result_gender.data.map((obj) => {
                        return {
                            label : obj.ognd_name,
                            value : obj.ognd_id
                        }
              });
          }catch(e){
            console.log(e);
          }; 
        }
        getDataGender();
       console.log( dataGender.ognd); ----------> Gender array
        return { 
            userId,
            dataGender
        };
  },

@chriscalo
Copy link

chriscalo commented Jul 11, 2024

UPDATED: Added two potential patterns for making internal mutations in classes reactive.

There's no mention of classes here that I could see. Classes are sometimes the right way to solve a problem and having to avoid them just to get Vue reactivity working is a pretty major downside.

I needed to find a way to trigger active updates when a class instance updates itself internally. Without doing anything special, these mutations don't go through any reactivity proxies, so Vue components using instances of a regular class aren't able to update.

I was able to find two plausible methods for getting this working.

The first method uses a "decorator" that takes an existing non-reactive class and returns an extension of the class that uses class constructor return overriding to return a proxy that wraps the new instance.

import { reactive } from "vue";

class NonReactiveFoo {
  id = null;
  
  doThing() {
    this.id = 1234;
  }
}

const ReactiveFoo = makeReactive(NonReactiveFoo);

const instance = new ReactiveFoo();
instance.doThing(); // should trigger reactive updates

function makeReactive(Class) {
  return class extends Class {
    constructor(...args) {
      super(...args);
      return reactive(this);
    }
  };
}

The second method is more direct: if you have control over the class, you can return a reactive object yourself from the constructor.

import { reactive } from "vue";

class ReactiveFoo {
  id = null;
  
  constructor() {
    return reactive(this);
  }
  
  doThing() {
    this.id = 1234;
  }
}

const instance = new ReactiveFoo();
instance.doThing(); // should trigger reactive updates

Having some official guidance on the right way to do this—whether similar to or different from what I've outlined here—without telling people to not use classes would be fantastic.

@idudinov
Copy link

idudinov commented Apr 3, 2025

hey @chriscalo , thanks for posting solution & example for classes! I need that too, for me there's no way to avoid classes, even if I wanted to...

Just wanted to add, that return reactive(this); seems dangerous and it actually is. Beware that if your class references this via arrow functions e.g. public doThing = () => { this.id = 1234; }; – that will not work, because this would point to and old, overwritten instance. Also, due to the nature of arrow functions, you won't be able to re-bind doThing to a reactive version of this.

@chriscalo
Copy link

Indeed there are gotchas with these approaches, which is why it would be helpful to have official guidance from the Vue team that's better than "don't use classes."

I would love to see an in-depth article in the style of Reactivity in Depth† that:

  1. outlines the core problem of class-based reactivity
  2. shows a naïve approach as well as the issues with it
  3. shows how to address as many of those issues as possible
  4. finally, enumerates the best alternative design patterns for class-based reactivity along with the issues and trade-offs for each

That seems like it would be a really useful article.

† It's puzzling that classes aren't mentioned anywhere in this article.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion Topics to discuss that don't have clear action items yet
Projects
None yet
Development

No branches or pull requests

8 participants