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:
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:
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>
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.
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.