Composition APIの使用例を考える(複数選択の状態管理を実装する)

6 min read読了の目安(約6000字

始めに

Vue3からComposition APIが使えるようになり、使用方法に関する記事も増えてきました。Composition APIを使うことでロジックをコンポーネントから分離することで再利用しやすいというメリットがあります。

https://qiita.com/karamage/items/7721c8cac149d60d4c4a

ただ一つ疑問があります。そんなに使いまわしたいケースってあるのか?

  • 共通化できるとは言えコードがあまりにも小さいので毎回書いた方が早い
    • カウンターとか
  • 見た目とロジックをまとめてコンポーネント化してしまった方が早い
    • 複雑な入力フォームとか(submitボタンも含めたコンポーネントにすると親コンポーネントがスッキリする)

基本的に見た目よりロジックの方が重いパターンじゃないと効果を発揮できないのでなかなかいい例が浮かびませんでした。
それでも何かいい例がないか考えて、一つ例が浮かびました。
それは 複数選択の状態管理(保存、キャンセルあり) です。
ただの複数選択ならチェックボックスで問題ないですが、編集状態があると以下の情報も必要になるのでたちまち複雑になります。

  • 既に選択しているIDリスト
  • 追加するIDリスト
  • 削除するIDリスト

複数選択は1つの画面で何回も出てくる可能性もあるので、上手く共通化できると嬉しいですよね。
そんなわけでこれを例に実装してみました。CodePenにアップしましたので動作確認や詳細の実装はこちらをご参照ください。

なお、記事での説明では普段使われるSFCで書き、codepenで書かれている実装とは異なりますので注意してください。SFCとそうでない場合の書き方の違いはこちらを参考にしてください。

https://qiita.com/wintyo/items/b43bede2b0b43c7ab31f

実装内容

MultiSelectionコンポーネントの作成

まず複数選択状態を表示するコンポーネントを作ります。
selectedIds, selectingIds, deleteIdsを渡して、選択済み、選択中、削除予定の表示ができるようにします。
実装は単純に対象のIdがどこに含まれているかをチェックして選択済み、選択中、削除予定を表すクラスを付与しています。またクリックした際の対象のoptionをイベントで送ります。

以下のデータが入っている時の表示内容

  • selectedIds: ['A', 'B']
  • selectingIds: ['C']
  • deleteIds: ['B']
MultiSelection.vue
<template lang="pug">
  .multi-selection
    template(v-for="option in cmpOptions")
      .multi-selection__item(
        :class="{\
          '-selected': option.isSelected,\
          '-selecting': option.isSelecting,\
          '-delete': option.isDelete,\
        }"
        @click="onClick(option)"
      )
        | {{ option.text }}
</template>

<script>
export default {
  emits: {
    select: (option) => {
      return option != null;
    },
  },
  props: {
    selectedIds: { type: Array },
    selectingIds: { type: Array },
    deleteIds: { type: Array },
    options: { type: Array },
  },
  setup(props, context) {
    const cmpOptions = computed(() => {
      return props.options.map((option) => {
        const isSelected = props.selectedIds.includes(option.id);
        const isSelecting = props.selectingIds.includes(option.id);
        const isDelete = props.deleteIds.includes(option.id);
        return {
          ...option,
          isSelected: !isDelete && isSelected,
          isSelecting,
          isDelete,
        };  
      });
    });
    
    return {
      cmpOptions,
      onClick: (option) => {
        context.emit('select', option);
      },
    };
  },
};
</script>

<style lang="scss">
// cssは割愛します。詳細はcodepenの方をご参照ください
</style>

複数選択状態管理のcomposableを作る

続いて複数選択状態を管理するcomposable関数を作ります。折角なので初期選択状態を引数で渡せるようにします。処理については選択時、キャンセル時、確定時それぞれに対してselectedIds, selectingIds, deleteIdsを更新します。

useMultiSelection.js
function useMultiSelection(initialSelectedIds = []) {
  const state = reactive({
    selectedIds: initialSelectedIds,
    selectingIds: [],
    deleteIds: [],
  });

  return {
    state,
    onSelect: (option) => {
      {
        // 選択中の場合は選択を外す
        const index = state.selectingIds.findIndex((id) => id === option.id);
        if (index !== -1) {
          state.selectingIds.splice(index, 1);
          return;
        }
      }

      {
        // 削除中の場合は削除リストから外す
        const index = state.deleteIds.findIndex((id) => id === option.id);
        if (index !== -1) {
          state.deleteIds.splice(index, 1);
          return;
        }
      }

      {
        // 選択済みの項目なら削除リストに追加する
        const index = state.selectedIds.findIndex((id) => id === option.id);
        if (index !== -1) {
          state.deleteIds.push(option.id);
          return;
        }
      }

      // それ以外は選択中として登録
      state.selectingIds.push(option.id);
    },
    onCancel: () => {
      state.selectingIds = [];
      state.deleteIds = [];
    },
    onConfirm: () => {
      const addedIds = _.union(state.selectedIds, state.selectingIds);
      const excludedIds = _.difference(addedIds, state.deleteIds);
      state.selectedIds = excludedIds;
      state.selectingIds = [];
      state.deleteIds = [];
    },
  };  
}

組み合わせてアプリケーションを完成させる

上記で作ったものを使って上手く連携させたら完成です。

App.vue
<template lang="pug">
  div
    .block
      .block__title MultiSelection1
      .block__content
        div
          button(@click="multiSelection.onCancel") cancel
          button(@click="multiSelection.onConfirm") confirm
        MultiSelection(
          :selectedIds="multiSelection.state.selectedIds"
          :selectingIds="multiSelection.state.selectingIds"
          :deleteIds="multiSelection.state.deleteIds"
          :options="OPTIONS"
          @select="multiSelection.onSelect"
        )
        div selectedIds: {{ multiSelection.state.selectedIds }}
        div selectingIds: {{ multiSelection.state.selectingIds }}
        div deleteIds: {{ multiSelection.state.deleteIds }}
</template>

<script>
const OPTIONS = [
  { id: 'A', text: 'A' },
  { id: 'B', text: 'B' },
  { id: 'C', text: 'C' },
  { id: 'D', text: 'D' },
  { id: 'E', text: 'E' }
];

export default {
  setup() {
    const multiSelection = useMultiSelection();
    
    return {
      OPTIONS,
      multiSelection,
    };
  },
};
</script>

<style lang="scss">
// cssは割愛します。詳細はcodepenの方をご参照ください
</style>

ロジックは全てuseMultiSelectionに入っているため、複製する場合はこちらをもう一度呼び出して使うだけでよくなります😄

  setup() {
    const multiSelection = useMultiSelection();
+   // もう一度呼び出すだけで同じロジックのものが使える
+   const multiSelection2 = useMultiSelection([OPTIONS[0].id]);
    
    return {
      OPTIONS,
      multiSelection,
+     multiSelection2,
    };
  }

終わりに

以上がComposition APIを使った実装例でした。今回の例はそこそこロジックが複雑で、かつ再利用する可能性が高く結構いい例なのかなと思っています。
この記事を作るにあたって、Composition APIが発揮するのはUIが単純な割りにロジックは複雑なパターンなのかなと思ってきました。ただロジックが複雑な場合って大体バックエンド側でやってくれているのでそういうケースってなかなかないんじゃないかなと思っています。。なので普段からメリットを享受するというより、使っていくうちにこういったロジックだけ複雑なパターンにぶち当たった時に助かるという感じなのかなと思っています。
Composition APIを使った例として参考にしていただけると幸いです。