vuejs3vue-composition-apipinia

Vue component not updating ref while using Pinia


Omitted import statements for the sake of being concise

inputData.ts

export const useInputDataStore = defineStore("inputData", {
    state: () => {
        return {
            currentlyActiveInput: -1
        }
    },
    actions: {
        focusInput(inputId: number) {
            this.currentlyActiveInput = inputId
        }
    }
})

InputBox.vue

<script setup lang="ts">

const store = useInputDataStore()

const props = defineProps<{
    isActive: boolean,
    inputId: number
}>();

const { isActive, inputId } = props;


const onClick = () => {
    store.focusInput(inputId);
}

const headerTag = ref<null | HTMLElement>(null);
onMounted(() => {
    if (isActive) {
        headerTag.value!!.style.visibility = "visible"
    } else {
        headerTag.value!!.style.visibility = "hidden"
    }
});

</script>
<template>
    <h1 ref="headerTag">is active </h1>
    <h1 @click="onClick">{{ inputId }}</h1>
</template>

ParentComponent.vue

<script setup lang="ts">

const store = useInputDataStore();
const { currentlyActiveInput } = storeToRefs(store);

</script>
<template>
    <InputBox
        :v-for="index in 10"
        :key="index"
        :is-active="index === currentlyActiveInput"
        :input-id="index"
        />
</template>

Here's what it will look like (I changed the font-size without showing you): example of it Desired Behavior

In the image there is a little gap between the letters. That's the invisible <h1> tag that I am trying to set to visible when the number is clicked (and then have it be set back to invisible when the next number is clicked).

What's Broken?

When you click on the number, nothing updates. The value in the store is changing (at least, I think). But none of the components get re-rendered. I believe the problem has to do with me using onMounted

Also...

I am aware that there is much simpler way to do this using this code

<h1 v-if="isActive">is active </h1>

I am trying to get a deeper understanding of Vue and comprehend why my code doesn't work.


Solution

  • 1. Destructuring props loses reactivity:

    const props = defineProps<{
      isActive: boolean,
      inputId: number
    }>()
    
    const { isActive, inputId } = props // ❌ loses reactivity
    

    The isActive and inputId won't change when the parent updates the value.
    So you can use the syntax when you know those props won't change for the entire lifecycle of the component, but I'd argue it's bad practice: it could be copied by a fellow developer into a another place where reactivity matters.
    To keep reactivity, use props.isActive inside <script> and note you can use isActive in <template>, without having to declare it as const - Vue does it for you).

    If you really want to destructure, you can keep reactivity by using:
    a) toRefs:

    import { toRefs } from 'vue'
    const props = defineProps<{
      isActive: boolean,
      inputId: number
    }>()
    const { isActive, inputId } = toRefs(props)
    

    b) computed for single props:

    import { computed } from 'vue'
    const isActive = computed(() => props.isActive)
    

    However, nobody really does it in the wild, because in both cases the resulting constants are ref()s, which means one still won't be able to use them as isActive/inputId inside <script> but as isActive.value, which is the same amount of chars as props.isActive.
    The only case where it's actually a gain in chars (excluding the destructuring code) is if you watch it, as watch can receive a reactive ref as watched expression; e.g:

    watch(isActive, onIsActiveChanged)
    

    So there's no gain, really.


    2. v-for doesn't need binding:

    The colon (:) prefix before a template argument/prop is a shorthand for v-bind: and is used to interpret the passed value as a JavaScript expression. However, v-for (along with other built-in vue directives, e.g: v-model, v-text, etc...) already expects a JS expression, and does not need the colon prefix.


    Note you only need the focused state in the store if it's used in any other place in your app. Otherwise, consider encapsulating the logic into a reusable component (see MyField.vue in this example).