Search code examples
vue.jsvuejs3vue-composition-apivue-typescript

vue 3 composition api component with array of checkboxes and toggle all


We've decided to gradually move from knockout to vue 3 composition api with typescript, and I'm trying to wrap my head around the anti-pattern of mutating props. I have a working component that is doing its intended job, but basically I would like confirmation that the way it's been written, is the suggested approach.

A fairly simple example is a checkbox-list component with a toggle all on top:

enter image description here

My biggest question is if what I'm doing in AppList.vue is correct, where I'm doing const internalModel = toRef(props.selected ?? []); to have an immutable variable IN the component and the selectedHandler-event and the toggleAll-computed to emit OUT, but here I'm manually keeping the selected and internalModel in sync. It feels cumbersome to have two variables for the same thing but at the same time it does make sense since the internalModel doesn't need to meddle towards the view.

I know there's an example on vuejs.org where an array can be used on a v-model for multiple checkboxes but that isn't inside a component or as a prop so it's not exactly the same, this feels more complex. I've been spending the better part of the day trying to getting it right but there's not that much vue 3 search results out and for this particular problem I haven't found any at all.

HomeView.vue :

<script set lang="ts">
import { ref } from 'vue';
import AppList, { type Item } from '@/components/AppList.vue';

const fooItems = ref<Item[]>([
    { id: 1, name: 'foo 1' },
    { id: 2, name: 'foo 2' },
    { id: 3, name: 'foo 3' },
    { id: 4, name: 'foo 4' },
    { id: 5, name: 'foo 5' },
]);
const fooSelected = ref<number[]>([]);
</script>

<template>
  <AppList :items="fooItems" v-model:selected="fooSelected"></AppList>
  <div>fooselected: {{ fooSelected }}</div>
</template>

Component/Applist.vue :

<script setup lang="ts">
import { computed, toRef } from 'vue';

export interface Item {
    id: number;
    name: string;
}

const props = defineProps<{
    items: Item[];
    selected?: number[];
}>();

const internalModel = toRef(props.selected ?? []);

const emit = defineEmits<{
    'update:selected': [selected: number[]];
}>();

const selectedHandler = (e: Event) => {
    const target = <HTMLInputElement>e.target;
    if (props.selected && target) {
        if (target.checked) {
            emit('update:selected', [...props.selected, Number(target.value)]);
        } else {
            emit(
                'update:selected',
                props.selected.filter((i: number) => i !== Number(target.value))
            );
        }
    }
};

const toggleAll = computed({
    get: () => internalModel.value.length === props.items.length && internalModel.value.every((s) => props.items.map((item) => item.id).includes(s)),
    set: (value) => {
        if (value) {
            emit(
                'update:selected',
                props.items.map((i) => i.id)
            );
            internalModel.value = props.items.map((i) => i.id);
        } else {
            emit('update:selected', []);
            internalModel.value = [];
        }
    },
});
</script>

<template>
    <label>
        <input type="checkbox" v-model="toggleAll" />
        toggle all
    </label>
    <ul>
        <li v-for="item in items" :key="item.id">
            <label>
                <input type="checkbox" :value="item.id" v-model="internalModel" @change="selectedHandler" />
                <span>id {{ item.name }}</span>
            </label>
        </li>
    </ul>
    internalModel: {{ internalModel }}
</template>


Solution

  • It seems to me that it can be done somehow simpler.
    fooItems should probably have an initial state 'checked'.
    In selectedHandler, just call emit().
    toggleAll will eventually make a function that works with internalModel.
    Here is an example for you Vue SFC Playground.


    HomeView.vue:

    <script setup lang="ts">
    import { ref } from 'vue';
    import AppList, { type Item } from './AppList.vue';
    
    const fooItems = ref<Item[]>([
      { id: 1, name: 'foo 1', checked: false },
      { id: 2, name: 'foo 2', checked: false },
      { id: 3, name: 'foo 3', checked: false },
      { id: 4, name: 'foo 4', checked: false },
      { id: 5, name: 'foo 5', checked: true },
    ]);
    const fooSelected = ref<number[]>([]);
    fooItems.value.map(item => item.checked && fooSelected.value.push(item.id))
    </script>
    
    <template>
      <AppList :items="fooItems" v-model:selected="fooSelected"></AppList>
      <div>fooselected: {{ fooSelected }}</div>
    </template>
    

    AppList.vue:

    <script setup lang="ts">
    import { ref } from 'vue';
    
    export interface Item {
      id: number;
      name: string;
      checked: boolean
    }
    
    const props = defineProps<{
      items: Item[];
      selected: number[]
    }>();
    
    const emit = defineEmits(['update:selected']);
    
    const internalModel = ref(props.selected);
      
    const selectedHandler = () => emit('update:selected', internalModel.value);
    
    const toggleAll = ($event) => {
      internalModel.value = [];
      if ( ($event.target as HTMLInputElement).checked ) {
        props.items.map(item => internalModel.value.push(item.id));
      }
      emit('update:selected', internalModel.value);
    };
    </script>
    
    <template>
      <label>
        <input type="checkbox" @change="toggleAll($event)" :checked="internalModel.length === items.length" />
        toggle all
      </label>
      <ul>
        <li v-for="item in items" :key="item.id">
          <label>
            <input type="checkbox" :value="item.id" v-model="internalModel" @change="selectedHandler(item.id)" :checked="item.checked"/>
            <span>{{ item.name }}</span>
          </label>
        </li>
      </ul>
      internalModel: {{ internalModel }}
    </template>