How can I reset the value of a ref and keep an associated watcher working? - vue.js

UPDATE:
I have achieved the desired behavior in the MCV by changing resetArray:
function resetArray() {
// myArray.value = [] // old version
myArray.value.length = 0 // new version
}
But I still don't understand why my MCV doesn't work.
ORIGINAL POST:
Background
In an app I am building, I store data in an a ref, created as const myArray = ref([]), which takes the form of an array of objects. This array is only changed in the following ways:
myArray.value[index] = {key: value}
myArray.value = [].
In particular, at no time is an object in myArray modified, it is either created or replaced.
I later added a watch which took action on every change to myArray.value. I discovered that after resetting myArray to [], the watcher stopped getting called.
Things I have tried:
I confirmed that my usage of ref follows the guidelines in this SO answer regarding ref vs reactive.
Refactoring to use watchEffect instead of watch. Did not help.
Refactoring to use reactive rather than ref. Did not help.
My Issue
In the MCV below, modifying myArray by calling addToArray works as intended: myArray.length is rendered and the first watch is triggered.
Calling resetArray triggers only the second watch, but the first watch IS NOT triggered when addToArray is called afterwards.
My Question
How can I both keep the ability to set myArray to [] and trigger actions every time myArray changes?
My MCV
View my MCV on Vue SFC Playground
The below code is the content of App.vue in a Vue project created with npm init vue#latest:
<script setup>
import {ref, watch} from "vue"
const myArray = ref([])
function addToArray() {
myArray.value.push("1")
}
function resetArray() {
myArray.value = []
}
watch(myArray.value, () => {
console.log("CLICKED!")
})
watch(myArray, () => {
console.log("RESET! clicked won't get called again!")
})
</script>
<template>
{{myArray.length}}<br />
<button #click="addToArray">CLICK ME</button><br />
<button #click="resetArray">RESET</button>
</template>

When watching a ref, use the ref itself -- not its value property -- as the watch source (the 1st argument to watch()).
To observe new array assignments or item additions/removals, pass the deep:true option (the 3rd argument to watch()):
watch(
myArray 1️⃣,
() => { /* handle change */ },
{ deep: true } 2️⃣
)
demo

Related

Why does watchEffect trigger while the watched value is not changed?

I have a component which keeps a local partial copy of an Pinia storage data.
<template>
<h3>Order idx {{ idx }}: sum = {{ localSum }}, store sum = {{ getOrderSum() }}</h3>
<input type="number" v-model="localSum" />
<button #click="updateSum">Save</button>
</template>
<script setup>
import { useCounterStore } from '../store/counter'
import { watchEffect, ref, defineProps, watch } from 'vue'
const props = defineProps({
idx: 0
})
const store = useCounterStore()
const localSum = ref(0)
function getOrderSum() {
return store.getOrderIdxSumMap[props.idx]
}
function updateSum() {
store.setOrderSum(props.idx, localSum.value)
}
watch(
() => getOrderSum(),
(newValue) => {
console.log('i am updated')
localSum.value = newValue
}, {
immediate: true,
}
)
/*
watchEffect(() => {
console.log('i am updated')
localSum.value = getOrderSum()
})
*/
</script>
Whenever external data changes the local copy should update. Using watchEffect instead of watch causes components with modified and unsaved data to lose user input.
watchEffect behaviour description 1:
Change first order data
Click save
You'll see i am updated twice within console.
watchEffect behaviour description 2:
Change the first order data
Change the second order data
Click save on the first order
You'll see the second order changes lost
Comment out watchEffect and uncomment watch. Now everything works just fine. Is it my misconseptions or a bug worth to be reported?
Full demo
It is how watch and watchEffectsupposed to work
In short, watch and watchEffect both tracks the change of their dependencies. When the dependencies changed they act like follow:
watch recalculates its source value and then compares the oldValue with newValue. If oldValue !== newValue, it will trigger the callback.
watchEffect has no sources so it triggers the callback anyway
In your example the dependencies of both watch and watchEffect are store.getOrderIdxSumMap and props.idx. The source of watch is the function () => getOrderSum(). So when the store.getOrderIdxSumMap changed, watchEffect will always trigger the callback but watch will re-calculate the value of () => getOrderSum(). If it changed too, watch will trigger its callback
When Vue detects a dependency is changed?
Whenever you set a value for reactive data, Vue uses a function to decide if the value is changed as follows:
export const hasChanged = (value: any, oldValue: any): boolean =>
!Object.is(value, oldValue)
So setting the same value for a primitive type (number, string, boolean...) will not be considered as a value change

Can't have a Vue shortcut (by reference) to a reactive variable?

I have a Pinia store with an object "objData", which holds one or more objects, with some additional metadata, which ends up becoming a fairly long variable. It has to be used in quite a number of places, therefore I made a "shortcut" variable instead to the "data" property. However, this shortcut fails to be reactive, whereas the variable i'm pointing to is reactive.
The Pinia object looks like:
objData: {
"fruit": {
data: {...},
...
},
"candy": {
data: {...},
...
},
}
The setup-function:
setup() {
const myStore = useMyStore()
// const fruit = myStore.objData['fruit'].data // <- direct, doesn't work
// const fruit = reactive(myStore.objData['fruit'].data) // <- reactive, doesn't work
const fruit = computed(() => myStore.objData['fruit'].data) // works
return {
myStore,
fruit,
}
}
The data change: (I'm sure I don't need to both do reactive() and refs(), or any at all, but I've tried all kinds of things to get reactivity in my shortcut). This happens in a composable that has access to the store.
if (!("fruit" in store.objData)) {
set(myStore.objData, "fruit", reactive({
data: ref(null),
}))
}
set(myStore.objData["fruit"], 'data', objNewData)
The page:
<div>
{{myStore.objData['fruits'].data.fruit_name}} OK
{{fruit.fruit_name}} OK, if computed(), otherwise not reactive
</div>
Unless I'm using a computed, I only get the inital value, which doesn't get updated when the store updates.
Is it actually bad/expensive/wrong to use a computed() to have a reactive data object in the page in this way? It "feels" wrong, but other than that I have no arguments against it.
(Why) is it not possible to simply make a variable by reference to a reactive variable, I always thought you're just pointing to a memory address.
I'm struggeling to provide an example, as this thing is so deeply integrated in my app. I'm at this point hoping for a glaring mistake on my part, or a simple answer that explains it.
Note 1: I'm using Vue2 with the composition API add-on.
Note 2: This is a very simplified example.

watch props update in a child created programmatically

I created the child using:
const ComponentClass = Vue.extend(someComponent);
const instance = new ComponentClass({
propsData: { prop: this.value }
})
instance.$mount();
this.$refs.container.appendChild(instance.$el);
When this.value is updated in the parent, its value doesn't change in the child. I've tried to watch it but it didn't work.
Update:
There's an easier way to achieve this:
create a <div>
append it to your $refs.container
create a new Vue instance and .$mount() it in the div
set the div instance's data to whatever you want to bind dynamically, getting values from the parent
provide the props to the mounted component from the div's data, through render function
methods: {
addComponent() {
const div = document.createElement("div");
this.$refs.container.appendChild(div);
new Vue({
components: { Test },
data: this.$data,
render: h => h("test", {
props: {
message: this.msg
}
})
}).$mount(div);
}
}
Important note: this in this.$data refers the parent (the component which has the addComponent method), while this inside render refers new Vue()'s instance. So, the chain of reactivity is: parent.$data > new Vue().$data > new Vue().render => Test.props. I had numerous attempts at bypassing the new Vue() step and passing a Test component directly, but haven't found a way yet. I'm pretty sure it's possible, though, although the solution above achieves it in practice, because the <div> in which new Vue() renders gets replaced by its template, which is the Test component. So, in practice, Test is a direct ancestor of $refs.container. But in reality, it passes through an extra instance of Vue, used for binding.
Obviously, if you don't want to add a new child component to the container each time the method is called, you can ditch the div placeholder and simply .$mount(this.$refs.container), but by doing so you will replace the existing child each subsequent time you call the method.
See it working here: https://codesandbox.io/s/nifty-dhawan-9ed2l?file=/src/components/HelloWorld.vue
However, unlike the method below, you can't override data props of the child with values from parent dynamically. But, if you think about it, that's the way data should work, so just use props for whatever you want bound.
Initial answer:
Here's a function I've used over multiple projects, mostly for creating programmatic components for mapbox popups and markers, but also useful for creating components without actually adding them to DOM, for various purposes.
import Vue from "vue";
// import store from "./store";
export function addProgrammaticComponent(parent, component, dataFn, componentOptions) {
const ComponentClass = Vue.extend(component);
const initData = dataFn() || {};
const data = {};
const propsData = {};
const propKeys = Object.keys(ComponentClass.options.props || {});
Object.keys(initData).forEach(key => {
if (propKeys.includes(key)) {
propsData[key] = initData[key];
} else {
data[key] = initData[key];
}
});
const instance = new ComponentClass({
// store,
data,
propsData,
...componentOptions
});
instance.$mount(document.createElement("div"));
const dataSetter = data => {
Object.keys(data).forEach(key => {
instance[key] = data[key];
});
};
const unwatch = parent.$watch(dataFn || {}, dataSetter);
return {
instance,
update: () => dataSetter(dataFn ? dataFn() : {}),
dispose: () => {
unwatch();
instance.$destroy();
}
};
}
componentOptions is to provide any custom (one-off) functionality to the new instance (i.e.: mounted(), watchers, computed, store, you name it...).
I've set up a demo here: https://codesandbox.io/s/gifted-mestorf-297xx?file=/src/components/HelloWorld.vue
Notice I'm not doing the appendChild in the function purposefully, as in some cases I want to use the instance without adding it to DOM. The regular usage is:
const component = addProgrammaticComponent(this, SomeComponent, dataFn);
this.$el.appendChild(component.instance.$el);
Depending on what your dynamic component does, you might want to call .dispose() on it in parent's beforeDestroy(). If you don't, beforeDestroy() on child never gets called.
Probably the coolest part about it all is you don't actually need to append the child to the parent's DOM (it can be placed anywhere in DOM and the child will still respond to any changes of the parent, like it would if it was an actual descendant). Their "link" is programmatic, through dataFn.
Obviously, this opens the door to a bunch of potential problems, especially around destroying the parent without destroying the child. So you need be very careful and thorough about this type of cleanup. You either register each dynamic component into a property of the parent and .dispose() all of them in the parent's beforeDestroy() or give them a particular selector and sweep the entire DOM clean before destroying the parent.
Another interesting note is that in Vue 3 all of the above will no longer be necessary, as most of the core Vue functionality (reactivity, computed, hooks, listeners) is now exposed and reusable as is, so you won't have to $mount a component in order to have access to its "magic".

vuejs handsontable official and calling handsontable method

I'm a beginner, this is probably more of a javascript problem than vue but anyway:
there a plugin for spreadsheet named handsontable and in the normal use you make the table by doing this
hot = new Handsontable(container, {option})
and then you can use the method like hot.loadData() etc..
To use handsontable with vuejs, there a wrapper we can find here https://github.com/handsontable/vue-handsontable-official. With the wrapper you make a table like this :
<template>
<div id="hot-preview">
<HotTable :root="root" :settings="hotSettings"></HotTable>
</div>
</template>
<script>
import HotTable from 'vue-handsontable-official';
import Vue from 'vue';
export default {
data: function() {
return {
root: 'test-hot',
hotSettings: {
data: [['sample', 'data']],
colHeaders: true
}
};
},
components: {
HotTable
}
mounted () {
localforage.config({
driver: localforage.INDEXEDDB,
name: 'matchlist-database'
})
localforage.getItem('DB').then(function (value) {
console.log('then i fetch the DB: ' + JSON.stringify(value))
if (value !== 'null') {
console.log('dB contain something')
**root**.loadData(value)
}
</script>
So it work fine when i give an array but to load the data from a DB you must call the handsontable method hot.loadData(data).
i cannot find how to call this method in vuejs i always get the error
TypeError: root.loadData is not a function
i tried with all i could think of instead of root ex: HotTable.loadData(value)
but to no avail
Can someone point me out how i would call handsontable methods from the vuejs wrapper. Or point me out what kind of reading i should do to understand my mistake. Thank a lot
There are two problems here, not bad ones :)
1st problem:
If you want to refer to your data inside Vue's methods/computed properties/watchers/lifecycle events, you should use the this keyword. If you have data: function() { return { root: "root-value" }} and you would like to console.log that "root-value" string, you should write console.log(this.root) inside your mounted handler.
If you had something like:
data: function() {
return {
hot = new Handsontable(container, {option})
....
};
You could call hot.loadData() like so:
mounted() {
this.hot.loadData();
...
}
So this refers to the Vue instance which exposes your data properties.
2nd problem:
If I understand the component wrapper correctly, you are supposed to pass data to it as props, not call any Handsontable methods directly.
<HotTable :root="root" :settings="hotSettings"></HotTable>
This means that Vue passes whatever you have as root in your data to the HotTable component. It also passes whatever you have as settings in your data. In the example, HotTable receives these:
root: 'test-hot',
hotSettings: {
data: [['sample', 'data']],
colHeaders: true
}
Now if you want to change/update/modify/add data that should be passed to the HotTable component, you should update your data in the Vue instance. You should do something like this.hotSettings = something new and this.root = something else and the HotTable component would receive those.
To understand what's really happnening with the HotTable, read all of the component documentation. Really. You will save lots of time if you read through the documentation. It all makes sense after that!
https://v2.vuejs.org/v2/guide/components.html

Can't copy props to model data and render it in Vue 2

I'm having this problem that looks a lot like a bug to me and I can't figure out how to solve it.
I created a generic list component and I tell it what child component it should insert in each item and what are the data it should pass to the child component. I'm passing everything as props along with the list (array) itself.
The problem is that I can't mutate the list props. So I try to copy it to model attribute. Otherwise I get this error:
Avoid mutating a prop directly since the value will be overwritten
whenever the parent component re-renders.....
And I can't just make it work in any of the lifecycle events. When I save the file and the hot-reloading reloads the page, the list is there, rendered, full of items. When I press F5 to manually reload the page, it is no more. Everything seems to be alright with code though
So in the parent component I'm doing this:
<List ref="link_list"
:list="this.foo.links" //this is array
:child="'LinkFormItem'" //this is the name of the child component
:section_name="'Links'"
:defaults="{content: '', type: 'facebook'}" />
In the List component I get this:
Template
<li class="" v-for="item in datalist">
<component :is="child" :item="item" ></component>
<button v-on:click='remove(index++)' type="button" name="button" class='red button postfix small'>Remove</button>
</li>
Script
<script>
import Child1 from './Child1'
import Child2 from './Child2'
export default {
name: 'search',
props: ['child', 'list', 'defaults','section_name'], //it is received as 'list'
components: {
Child1, Child2
},
data () {
return {
index: 0,
datalist: [] //i'm trying to copy 'list' to 'datalist'
}
},
beforeMount: function () {
// i'm copying it
for(var k in this.list){
this.datalist.push(this.list[k])
}
},
methods: {
//and here I should change it the way I want
add: function () {
this.datalist.push(this.defaults)
},
getList () {
return this.datalist;
},
remove(index){
var datalist = [];
for(var k in this.datalist){
if(k != index) datalist.push(this.datalist[k]);
}
this.datalist = datalist;
}
}
}
</script>
I don't see any problems with my Script. What is going on??
#edit
Ok, some console.log later I found out what the problem seems to be. The HTTP Request is really taking much longer than the mounting of the component to happen. But when it happens, it is not triggering the update in the list component. Nothing is re-rendered and the list is empty.
Workaround
well I realised the problem was related to propagation. I made a few changes in the code to asure the parent component was updating and changing the model value. but the child component (the list component) was not receiving it.
then I gave up trying to understand why and did the following:
1- used the ref in the child component to force an update in the child component with $forceUpdate and then I was assigning the props to the model in the beforeUpdate event. It was causing an error: an re-rendering loop. The update caused a new update and so on. We could just use a flag to stop it.
2- Instead I just called a child method directly:
this.$refs.link_list.updateList(data.links);
I hate this approach because I think it's way too explicit. But it did the job. Then in the child component a new method:
updateList(list){
this.datalist = list;
}
3- The other possibility that passed through my mind was emitting an event. But I didn't try, too complicated
You can simply do like as follows
data () {
return {
index: 0,
datalist: this.list // to copy props to internal component data
}
},
Once you done about you need to apply data manipulation opertions on new this.datalist , not on this.list
If you don't want to mutate the original list array you can do this:
data () {
return {
index: 0,
datalist: Object.assign({}, this.list)
}
}
I think this will help you