Search code examples
vue.jsvuejs3vue-component

How to sync a dynamic property from a parent component to a child component in Vue 3


I have some products that I want to filter based on their category using checkboxes.

To do so, I have a parent component which passes the possible categories (e.g. A, B and C) to the child and keeps track of the checked/selected categories.

However, the tricky part is that the possible categories are dynamic: sometimes the array of categories may shrink or grow.

This results in incorrect behaviour due to the booleans of the checkboxes not corresponding anymore to the correct positions. For example, the first two checkboxes are checked, but whenever the array changes, the first two values are still checked (which are now different values). This is demonstrated here:

Vue Demo

I managed to solve the issue by letting the child keep track of the checkboxes itself with a local property and emit to the parent whenever updated. However, if I add a second child then child 1 and child 2 are not synced anymore (for example when I would use 1 filter in the mobile menu and 1 in the normal menu). This is shown here:

Vue Demo

So both solutions are not optimal yet and any help is appreciated for this puzzle!

Below is the simplified code. Whenever the minimal-price filter changes, the FilterOptions array only shows the possible categories of products that meet the price criteria. When this happens, the positions do not correspond anymore to the correct values.

Child Component - FilterCheckboxItem.vue

<template>

<div
  v-for="(FilterOption, index) in FilterOptions"
> 
  <label for="lname">{{FilterOption}}</label>   
  <input
    type="checkbox"
    :value="FilterOption"
    v-model="modelValue[index]"
  />
</div>


</template>

<script>
export default {
    props: {
     FilterOptions: {
      type: Array,
    },
    modelValue: {
      type: Array,
    },
 },
}
</script>

Parent Component

<template>


  <FilterCheckboxItem
    ref="Category"
    name="Category"
    :FilterOptions="CategoriesWithinPriceReq"
    v-model="FilteredCategories"
  />


<input v-model="MinimalPrice">

</template>

<script>
import FilterCheckboxItem from './Comp.vue'
export default {
  components: { FilterCheckboxItem },
  data(){
    return {
      FilteredCategories: [],
      CategoryOptions: ["A","B","C"],
      MinimalPrice: 5,

      Products: 
        [
        {name: 'test1', price: 10, category: "A"},
        {name: 'test2', price: 15, category: "B"},
        {name: 'test3', price: 20, category: "C"},
        {name: 'test4', price: 8, category: "C"}

        ]  
    }
  },

  computed: {
    FilteredProducts: function(){
    return this.filterProductsByCategory(this.filterProductsByPrice(this.Products))
    },
    CategoriesWithinPriceReq: function(){
      let CategoriesMeetingFilter = this.filterProductsByPrice(this.Products);
      
 
      let uniqueCatgeoriesMeetingFilters = [
        ...new Set(CategoriesMeetingFilter.map(({ category }) => category)),
      ];
      return uniqueCatgeoriesMeetingFilters;
      
    },
  },

  methods: {
    filterProductsByPrice: function(products){
      return products.filter((product)=>product.price>=this.MinimalPrice)
      },
    filterProductsByCategory: function(products){
      const selected_categories = this.CategoriesWithinPriceReq.filter((category, bool) => this.FilteredCategories[bool])
      const results = products.filter((product) => selected_categories.indexOf(product.category) !==-1)
      // Only filter if atleast one box is checked, otherwise return all products
      return selected_categories && selected_categories.length? results: products
      }
  }
}
</script>

Solution

  • Each child updates FilteredCategories through the v-model:

      <div>Child 1</div>
      <FilterCheckboxItem
        :FilterOptions="CategoriesWithinPriceReq"
        v-model="FilteredCategories"
      />
    
        <div>Child 2</div>
      <FilterCheckboxItem
        :FilterOptions="CategoriesWithinPriceReq"
        v-model="FilteredCategories"
      />
    

    When one of the components updates the variable, the other one has to update the checked state of its checkboxes. So it has to react to incoming changes. You can easily do this with a watcher:

    // FilterCheckboxItem.vue
      setup(props, { emit }) {
        ...
        watch(
          () => props.modelValue, 
          () => checked.value = props.modelValue
        )
        ...
      },
    

    Here is the updated playground


    However, this will cause the child component to update an array which is passed from the parent (the modelValue from the prop goes to checked, which is updated by the checkboxes). This is considered poor form. Due to the watcher on checked, it is not possible to just use a copy of modelValue (i.e. checked.value = [...props.modelValue]), as it leads to an endless loop (setting checked leads to an emit, which updates modelValue, which sets checked again).

    The way around it is to only emit when checkboxes are clicked by setting an @input handler. Since you are using the checkbox array mode of v-model, you have to wait until the array is updated before emitting:

      <input
        type="checkbox"
        :value="filterOption"
        v-model="checked"
        @input="emitOnNextCycle"
      />
    

    and

      setup(props, { emit }) {
        const checked = ref([]);
        const emitOnNextCycle = () => setTimeout(() => emit('update:modelValue', checked.value))
        watch(
          () => props.modelValue, 
          () => checked.value = [...props.modelValue]
        )
        return { checked, emitOnNextCycle };
      },
    

    See playground

    Instead of setTimeout() you could also use nextTick() or Promise.resolve(), they all do the same in this case.


    There is a third option, where you don't use a local copy at all, but instead pass on data and events directly. This does not work with v-model, you have to use the underlying data binding and event, usually :modelValue and @update:modelValue, but since you are working with native checkboxes, it is :checked and @input and you have to write your own array mode:

    <!-- in template -->
    <input
      type="checkbox"
      :checked="modelValue.includes(filterOption)"
      @input="event => onInput(filterOption, event.target.checked)"
    />
    

    and

    setup(props, { emit }) {
      const onInput = (option, isChecked) => {
        const selection = props.modelValue.filter(selectedOption => selectedOption !== option)
        if (isChecked){
          selection.push(option)
        }
        emit('update:modelValue', selection)
      }
      return {onInput}
    }
    

    Now the component does not an internal state anymore, the :checked prop reacts directly to changes to modelValue, and @input just emits the update event. This is probably the easiest solution, but it is not always possible.

    playground


    As a tip, avoid mixing options API elements and setup function, it is confusing and leads to unclear behavior. Also, the vast majority of JS programmers uses camelCase for variables names, PascalCase is reserved for classes and types. Make of that what you will.

    Hope it helps.