At what point are props contents available (and are they reactive once they are)? - vue.js

I pass data into a component via props:
<Comment :comment="currentCase.Comment" #comment="(c) => currentCase.Comment=c"></Comment>
currentCase is updated via a fetch call to an API during the setup of the component (the one that contains the line above)
The TS part of <Comment> is:
<script lang="ts" setup>
import { Comment } from 'components/helpers'
import { ref, watch } from 'vue'
const props = defineProps<{comment: Comment}>()
const emit = defineEmits(['comment'])
console.log(props)
const dateLastUpdated = ref<string>(props.comment?.DateLastUpdated as string)
const content = ref<string>(props.comment?.Content as string)
watch(content, () => emit('comment', {DateLastUpdated: dateLastUpdated, Content: content}))
</script>
... where Comment is defined in 'components/helpers' as
export class Comment {
DateLastUpdated?: string
Content?: string
public constructor(init?: Partial<Case>) {
Object.assign(this, init)
}
}
content is used in the template, but is empty when the component is rendered. I added a console.log() to check whether the props were known - and what is passed is undefined at that point:
▸ Proxy {comment: undefined}
When looking at the value of the props once the application is rendered, their content is correct:
{
"comment": {
"DateLastUpdated": "",
"Content": "comment 2 here"
}
}
My question: why is comment not updated when props are available (and when are their content available?)
I also tried to push the update later in the reactive cycle, but the result is the same:
const dateLastUpdated = ref<string>('')
const content = ref<string>('')
onMounted(() => {
console.log(props)
dateLastUpdated.value = props.comment?.DateLastUpdated as string
content.value = props.comment?.Content as string
watch(content, () => emit('comment', {DateLastUpdated: dateLastUpdated, Content: content}))
})

Vue lifecycle creates component instances from parent to child, then mounts them in the opposite order. Prop value is expected to be available in a child if it's available at this time in a parent. If currentCase is set asynchronously in a parent, the value it's set to isn't available on component creation, it's a mistake to access it early.
This disables the reactivity:
content.value = props.comment?.Content as string
props.comment?.Content === undefined at the time when this code is evaluated, it's the same as writing:
content.value = undefined;
Even if it weren't undefined, content wouldn't react to comment changes any way, unless props.comment is explicitly watched.
If content is supposed to always react to props.comment changes, it should be computed ref instead:
const content = computed(() => props.comment?.Content as string);
Otherwise it should be a ref and a watcher:
const content = ref();
const unwatch = watchEffect(() => {
if (props.comment?.Content) {
content.value = props.comment.Content;
unwatch();
...
}
});

Related

How to update multiple Vue 3 comonents when Pinia array changes

I can't get multiple components accessing the same store respond to updates, until I mess with a dom element to trigger new render.
In my Pinia store, I have an array, and an update method:
let MyArray: IMyItem[] = [
{ id: 1,
name: "First Item",
}, ....
let SelectedItem = MyArray[0]
const AddItem = ( n: string, ) => {
MyArray.push({ id: createId(), name: n, });
};
return { MyArray, SelectedItem, AddItem }
In one Vue component, I have text inputs and a button to call the store's method:
function handle() {store.AddItem(name.value));
In another Vue component, on the same parent, I use a for loop to display allow for selecting an item:
<div v-for="item in store.MyArray">
<input type="radio"...
No changes with these efforts:
const { MyArray } = storeToRefs(store);
const myArray = reactive(store.MyArray);
// also watching from both components...
watch(store.MyArray, (n, o) => console.dir(n));
// also... lots of other stuff.
const myArray = reactive(store.MyArray);
watch(myArray, (n, o) => console.dir(n));
I also experimented with <form #submit.prevent="handle"> triggering nextTick by adding a string return to the store's method.
I assume the reason clicking around makes it work is because I'm changing the the store's SelectedItem, and its reactivity calls for re-rendering, as it is v-model for a label.
The docs say Array.push should be doing it's job... it just isn't bound the same way when used in v-for.
What's needed to trigger the dom update? Thanks! 💩
As comments pointed out, the main issue is your store state is not declared with the Reactivity API, so state changes would not trigger watchers and would not cause a re-render.
The solution is to declare MyArray as a reactive and SelectedItem as a ref:
// store.js
import { defineStore } from 'pinia'
import type { IMyItem } from './types'
import { createId } from './utils'
👇 👇
import { ref, reactive } from 'vue'
export const useItemStore = defineStore('item', () => {
👇
let MyArray = reactive([{ id: createId(), name: 'First Item' }] as IMyItem[])
👇
let SelectedItem = ref(MyArray[0])
const AddItem = (n: string) => {
MyArray.push({ id: createId(), name: n })
}
return { MyArray, SelectedItem, AddItem }
})
If using storeToRefs(), make sure to set the ref's .value property when updating the SelectedItem:
// MyComponent.vue
const store = useItemStore()
const { SelectedItem, MyArray } = storeToRefs(store)
const selectItem = (id) => {
👇
SelectedItem.value = MyArray.value.find((item) => item.id === id)
}
But in this case, it's simpler to use the props off the store directly:
// MyComponent.vue
const store = useItemStore()
const selectItem = (id) => {
store.SelectedItem = store.MyArray.find((item) => item.id === id)
}
demo

How to generate computed props on the fly while accessing the Vue instance?

I was wondering if there is a way of creating computed props programatically, while still accessing the instance to achieve dynamic values
Something like that (this being undefined below)
<script>
export default {
computed: {
...createDynamicPropsWithTheContext(this), // helper function that returns an object
}
}
</script>
On this question, there is a solution given by Linus: https://forum.vuejs.org/t/generating-computed-properties-on-the-fly/14833/4 looking like
computed: {
...mapPropsModels(['cool', 'but', 'static'])
}
This works fine but the main issue is that it's fully static. Is there a way to access the Vue instance to reach upon props for example?
More context
For testing purposes, my helper function is as simple as
export const createDynamicPropsWithTheContext = (listToConvert) => {
return listToConvert?.reduce((acc, curr) => {
acc[curr] = curr
return acc
}, {})
}
What I actually wish to pass down to this helper function (via this) are props that are matching a specific prefix aka starting with any of those is|can|has|show (I'm using a regex), that I do have access via this.$options.props in a classic parent/child state transfer.
The final idea of my question is mainly to avoid manually writing all the props manually like ...createDynamicPropsWithTheContext(['canSubmit', 'showModal', 'isClosed']) but have them populated programatically (this pattern will be required in a lot of components).
The props are passed like this
<my-component can-submit="false" show-modal="true" />
PS: it's can-submit and not :can-submit on purpose (while still being hacked into a falsy result right now!).
It's for the ease of use for the end user that will not need to remember to prefix with :, yeah I know...a lot of difficulty just for a semi-colon that could follow Vue's conventions.
You could use the setup() hook, which receives props as its first argument. Pass the props argument to createDynamicPropsWithTheContext, and spread the result in setup()'s return (like you had done previously in the computed option):
import { createDynamicPropsWithTheContext } from './props-utils'
export default {
â‹®
setup(props) {
return {
...createDynamicPropsWithTheContext(props),
}
}
}
demo
If the whole thing is for avoiding using a :, then you might want to consider using a simple object (or array of objects) as data source. You could just iterate over a list and bind the data to the components generated. In this scenario the only : used are in the objects
const comps = [{
"can-submit": false,
"show-modal": true,
"something-else": false,
},
{
"can-submit": true,
"show-modal": true,
"something-else": false,
},
{
"can-submit": false,
"show-modal": true,
"something-else": true,
},
]
const CustomComponent = {
setup(props, { attrs }) {
return {
attrs
}
},
template: `
<div
v-bind="attrs"
>{{ attrs }}</div>
`
}
const vm = Vue.createApp({
setup() {
return {
comps
}
},
template: `
<custom-component
v-for="(item, i) in comps"
v-bind="item"
></custom-component>
`
})
vm.component('CustomComponent', CustomComponent)
vm.mount('#app')
<script src="https://unpkg.com/vue#3"></script>
<div id="app">{{ message }}</div>
Thanks to Vue's Discord Cathrine and skirtle folks, I achieved to get it working!
Here is the thread and here is the SFC example that helped me, especially this code
created () {
const magicIsShown = computed(() => this.isShown === true || this.isShown === 'true')
Object.defineProperty(this, 'magicIsShown', {
get () {
return magicIsShown.value
}
})
}
Using Object.defineProperty(this... is helping keeping the whole state reactive and the computed(() => can reference some other prop (which I am looking at in my case).
Using a JS object could be doable but I have to have it done from the template (it's a lower barrier to entry).
Still, here is the solution I came up with as a global mixin imported in every component.
// helper functions
const proceedIfStringlean = (propName) => /^(is|can|has|show)+.*/.test(propName)
const stringleanCase = (string) => 'stringlean' + string[0].toUpperCase() + string.slice(1)
const computeStringlean = (value) => {
if (typeof value == 'string') {
return value == 'true'
}
return value
}
// the actual mixin
const generateStringleans = {
created() {
for (const [key, _value] of Object.entries(this.$props)) {
if (proceedIfStringlean(key)) {
const stringleanComputed = computed(() => this[key])
Object.defineProperty(this, stringleanCase(key), {
get() {
return computeStringlean(stringleanComputed.value)
},
// do not write any `set()` here because this is just an overlay
})
}
}
},
}
This will scan every .vue component, get the passed props and if those are prefixed with either is|can|has|show, will create a duplicated counter-part with a prefix of stringlean + pass the initial prop into a method (computeStringlean in my case).
Works great, there is no devtools support as expected since we're wiring it directly in vanilla JS.

vue 3 composition api, passing data and making it reactive

In my component I have a simple select menu with two options ("all", and "Investment"). The idea here is to get an array of data from a composable, and display on screen each row of this data. If I select "all" in the menu it displays all rows, if I select "Investment" it will filter the data and display only those with obj.link == "usa".
Once I fetch the data and bring it into my component, if I console.log the data, it works fine. If I console.log the data after i filter it, I get an empty array.
I have then tried to hard code the data in my component and test the filter function, and it works fine. So the error comes from how I am getting my data and how I try to use it. I have tried to use different hooks such as onMounted, but was unsuccessfull.
Here is a minimalistic sample of my code.
Any suggestion or advice is more than welcome
The composable that fetches the data from my database looks like this:
import {ref} from 'vue'
import { projectFirestore } from '../firebase/config'
import { collection, getDocs } from "firebase/firestore";
const getActorDocs = () => {
const actorDocs = []
const error = ref(null)
const loadActors = async () => {
try {
const querySnapshot = await getDocs(collection(projectFirestore, "actors"));
querySnapshot.docs.map(doc => {
actorDocs.push(doc.data())
})
} catch (err) {
error.value = err.message
console.log(error.value)
}
}
return { actorDocs, error, loadActors}
}
export default getActorDocs
My component:
<template>
<div class="col-2">
<span class="lbl">MA</span>
<select v-model="selectedMA" class="form-select" >
<option value="all">all</option>
<option value="Investment">Investment</option>
</select>
</div>
<p v-for="obj in actorListTest2" :key="obj" :value="obj"> {{obj}} </p>
<template/>
<script >
import {onMounted, onBeforeMount, ref} from 'vue'
import getActorDocs from '../../composables/getActorDocs'
export default {
setup(){
const selectedMA = ref("Investment")
const error = ref(null)
const {actorDocs, loadActors} = getActorDocs()
var actorListTest1 = actorDocs
const actorListTest2 = ref([])
loadActors() // loads actors array into actorDocs
actorListTest2.value = actorListTest1
console.log(actorListTest1) // <----- prints correctly (see image below)
if(selectedMA.value === "all"){
actorListTest2.value = actorListTest1
}else{
actorListTest2.value = actorListTest1.filter(obj => {
return obj.link == selectedMA.value
})
}
console.log(actorListTest2.value) // <----- prints undefined !
return { error, selectedMA, actorListTest2}
}//setup
}
</script>
This is the output of console.log(actorListTest1):
Then this is the output of console.log(actorListTest2) after filtering :
This is a known problem with console.log, it shouldn't be used to debug object values in real time.
actorDocs is not reactive and won't work correctly with asynchronous operations in Vue. Side effects are supposed to be done in lifecycle hooks, e.g.: mounted.
In current state getActorDocs isn't ready to be used with composition API because it's limited to follow promise control flow in order to avoid this race condition:
onMounted(async () => {
await loadActors();
console.log(actorListTest2.value);
});
A correct way to avoid this is to make actorDocs reactive array or a ref:
const actorDocs = reactive([]);
In case there's a need to access filtered value in side effect, e.g. console.log, this is done in a watcher
const actorListTest2 = computed(() => actorDocs.filter(...));
watch(actorListTest2, v => console.log(v));
onMounted(() => {
loadActors();
});

reactive object not updating on event emitted from watch

i'm building a complex form using this reactive obj
const formData = reactive({})
provide('formData', formData)
inside the form one of the components is rendered like this:
<ComboZone
v-model:municipality_p="formData.registry.municipality"
v-model:province_p="formData.registry.province"
v-model:region_p="formData.registry.region"
/>
this is the ComboZone render function:
setup(props: any, { emit }) {
const { t } = useI18n()
const { getters } = useStore()
const municipalities = getters['registry/municipalities']
const _provinces = getters['registry/provinces']
const _regions = getters['registry/regions']
const municipality = useModelWrapper(props, emit, 'municipality_p')
const province = useModelWrapper(props, emit, 'province_p')
const region = useModelWrapper(props, emit, 'region_p')
const updateConnectedField = (key: string, collection: ComputedRef<any>) => {
if (collection.value && collection.value.length === 1) {
console.log(`update:${key} => ${collection.value[0].id}`)
emit(`update:${key}`, collection.value[0].id)
} else {
console.log(`update:${key} =>undefined`)
emit(`update:${key}`, undefined)
}
}
const provinces = computed(() => (municipality.value ? _provinces[municipality.value] : []))
const regions = computed(() => (province.value ? _regions[province.value] : []))
watch(municipality, () => updateConnectedField('province_p', provinces))
watch(province, () => updateConnectedField('region_p', regions))
return { t, municipality, province, region, municipalities, provinces, regions }
}
useModelWrapper :
import { computed, WritableComputedRef } from 'vue'
export default function useModelWrapper(props: any, emit: any, name = 'modelValue'): WritableComputedRef<any> {
return computed({
get: () => props[name],
set: (value) => {
console.log(`useModelWrapper update:${name} => ${value}`)
emit(`update:${name}`, value)
}
})
}
problem is that the events emitted from useModelWrapper update the formData in the parent template correctly, the events emitted from inside the watch function are delayed by one render....
TL;DR;
Use watchEffect instead of watch
...with the caveat that I haven't tried to reproduce, my guess is that you're running into this issue because you're using watch which runs lazily.
The lazy nature is a result of deferring execution, which is likely why you're seeing it trigger on the next cycle.
watchEffect
Runs a function immediately while reactively tracking its dependencies and re-runs it whenever the dependencies are changed.
watch
Compared to watchEffect, watch allows us to:
Perform the side effect lazily;
Be more specific about what state should trigger the watcher to re-run;
Access both the previous and current value of the watched state.
found a solution, watchEffect wasn't the way.
Looks like was an issue with multiple events of update in the same tick, worked for me handling the flush of the watch with { flush: 'post' } as option of the watch function.
Try to use the key: prop in components. I think it will solve the issue.

Vue composition api is not updating bound value to text field

I am updating an attribute of an object after initialization.
My dumbed-down component looks like this:
<template lang="pug">
div
v-text-field(v-model="object.name")
v-text-field(v-model="object.vpnPort")
</template>
<script>
import { ref } from '#vue/composition-api'
export default {
setup(props, { root }) {
const object = ref({})
getNextPort().then(response => (object.value.vpnPort = response.data))
return { object }
}
}
</script>
In this example, getNextPort is an API call that returns a number. For some reason, the v-text-field is not updated. I do not see the value in the input field. When I console.log the object after the getNextPort call it shows:
{"vpnPort":10001}
Which is the expected result. I also tried:
$nextTick
onMounted
$forceUpdate
But when I start typing in the name field the vpnPort doest get updated!
Does anybody know why the result is not shown in the v-text-field?
You should initialize your object data with empty fields like :
const object = ref({
name:'',
vpnPort:null
})
for reactive objects, you should use reactive
const object = reactive({
name: '',
vpnPort: null
})
change
object.value.vpnPort = response.data
to
object.vpnPort = response.data
check out https://composition-api.vuejs.org/#ref-vs-reactive for more info