2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初めてのComposable実装メモ

2
Posted at

はじめに

Laravel + Vueで家計簿アプリを開発している中で、初めて**Composerble(コンポーザブル)**を導入した時の備忘録です。
フォーム処理や一覧表示、グラフ描画とのリアルタイム連携など、実装時に学んだことをまとめます。

Composable(コンポーザブル)とは?

VueのComposition APIでロジックを関数として切り出して再利用可能にする仕組みです。

特徴

  • ロジックをuseXxxx.jsというファイルに分離して管理
  • Vueのref,'reactive()', 'watch()'などそのまま使える
  • 複数コンポーネントで共通機能を共有・再利用できる

Composable導入ステップ

  1. composablesフォルダを作成
    慣習的に/js/composablesディレクトリを作ります
    /js
        /components
        /composables
            useExpenseForm.js # Composableファイル
    
  2. Composable関数を作成
    // composables/useExpenseForm.js
    import { reactive, watch } from 'vue';
    import axios from 'axios';
    import Swal from 'sweetalert2';
    
    export function useExpenseForm(props, emit) {
      const form = reactive({
        amount: props.expense.amount ?? '',
        date: props.expense.date ?? '',
        title: props.expense.title ?? '',
        category_id: props.expense.category_id ?? '',
        back: props.back,
      });
    
      // props.expenseの変更を監視
      watch(() => props.expense, (newExpense) => {
        form.amount = newExpense.amount ?? '';
        form.date = newExpense.date ?? '';
        form.title = newExpense.title ?? '';
        form.category_id = newExpense.category_id ?? '';
      });
    
      // 送信処理
      const submit = async () => {
        try {
          const response = props.method === 'post'
            ? await axios.post(props.submitUrl, form)
            : await axios.put(props.submitUrl, form);
    
          Swal.fire({
            toast: true,
            position: 'top-end',
            icon: 'success',
            title: response.data.message,
            showConfirmButton: false,
            timer: 2000,
            timerProgressBar: true,
          }).then(() => {
            if (props.method === 'put') {
              window.location.href = '/dashboard';
            }
          });
    
          emit('expense-added');
          form.amount = '';
          form.date = '';
          form.title = '';
          form.category_id = '';
        } catch (error) {
          Swal.fire({
            toast: true,
            position: 'top-end',
            icon: 'error',
            title: '登録失敗しました',
            showConfirmButton: false,
            timer: 2000,
            timerProgressBar: true,
          });
          console.error('登録失敗', error);
        }
      };
    
      return { form, submit };
    }
    
  3. コンポーネントで利用する
    <!-- ExpenseForm.vue -->
    <script setup>
    import { defineEmits } from 'vue';
    import { useExpenseForm } from '@/composables/useExpenseForm';
    import PrimaryButton from '@/Components/PrimaryButton.vue';
    import InputLabel from '@/Components/InputLabel.vue';
    import TextInput from '@/Components/TextInput.vue';
    
    const props = defineProps({
      expense: Object,
      categories: Array,
      submitUrl: String,
      method: { type: String, default: 'post' },
      back: { type: String, default: 'dashboard' },
    });
    
    const emit = defineEmits(['expense-added']);
    const { form, submit } = useExpenseForm(props, emit);
    </script>
    
    <template>
      <form @submit.prevent="submit">
        <div>
          <InputLabel for="amount" value="金額" />
          <TextInput id="amount" type="number" v-model="form.amount" />
        </div>
        <!-- 他の項目も同様にv-modelでバインド -->
      </form>
    </template>
    
    

Composableのメリット

特徴 説明
再利用 複数のコンポーネントでuseExpenseForm()を共有可能
関心の分離 UIとロジックを分離し、管理しやすくなる
テストしやすい ロジック単位でユニットテスト可能
Vue機能を活用 watch(),ref(),computed()がそのまま使える

命名規則(Vueの慣例)

Composableは**「use + 名詞」**で命名します。
例:

  • useExpenseForm
  • useChartData
  • usePagination
  • useUserAuth

一覧表示をコンポーネント化

フォームだけでなく、一覧表示もコンポーネントとして分離しました。

  1. ExpenseList.vueを作成
    <!-- resources/js/Components/ExpenseList.vue -->
    <script setup>
    import { ref, watch, onMounted } from 'vue';
    import { usePage, Link } from '@inertiajs/vue3';
    import DeleteButton from '@/Components/DeleteButton.vue';
    import Pagination from '@/Components/Pagination.vue';
    import axios from 'axios';
    
    const props = defineProps({
      initialExpenses: Object,
      refreshKey: Number,
    });
    
    const page = usePage();
    const currentPage = ref(props.initialExpenses.current_page || 1);
    const expenseList = ref(props.initialExpenses.data ?? []);
    const expenses = ref(props.initialExpenses);
    
    const emit = defineEmits(['expenses-updated']);
    
    // データ再取得
    const reloadExpenses = async () => {
      try {
        const response = await axios.get(route('expenses.latestJson', { page: currentPage.value }));
        expenseList.value = response.data.expenses.data;
        expenses.value = response.data.expenses;
        emit('expenses-updated');
      } catch (e) {
        console.error('再取得エラー', e);
      }
    };
    
    // フラッシュメッセージ
    watch(
      () => page.props.flash?.message,
      (message) => {
        if (message) {
          Swal.fire({
            toast: true,
            position: 'top-end',
            icon: 'success',
            title: message,
            showConfirmButton: false,
            timer: 2000,
            timerProgressBar: true,
          });
        }
      }
    );
    
    onMounted(() => {
      if (page.props.flash?.message) {
        Swal.fire({
          toast: true,
          position: 'top-end',
          icon: 'success',
          title: page.props.flash.message,
          showConfirmButton: false,
          timer: 2000,
          timerProgressBar: true,
        });
      }
    });
    
    defineExpose({ reloadExpenses });
    </script>
    
    <template>
      <div>
        <p class="text-lg font-bold mb-4">最近の記録</p>
        <table class="table-auto w-full mb-4">
          <thead>
            <tr>
              <th class="px-4 py-2">金額</th>
              <th class="px-4 py-2">日付</th>
              <th class="px-4 py-2">費用名</th>
              <th class="px-4 py-2">カテゴリー</th>
              <th class="px-4 py-2">操作</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="expense in expenseList" :key="expense.id">
              <td class="border px-4 py-2">{{ expense.amount }}</td>
              <td class="border px-4 py-2">{{ expense.date }}</td>
              <td class="border px-4 py-2">{{ expense.title }}</td>
              <td class="border px-4 py-2">{{ expense.category?.name ?? '未分類' }}</td>
              <td class="border px-4 py-2">
                <Link :href="route('expenses.edit', { expense: expense.id, back: 'dashboard' })" class="text-blue-500 hover:underline">編集</Link>
                <DeleteButton :expenseId="expense.id" @deleted="reloadExpenses" />
              </td>
            </tr>
          </tbody>
        </table>
        <Pagination :links="expenses.links" />
      </div>
    </template>
    
    
  2. Dashboard.vueで読み込み
    <ExpenseList
      :initial-expenses="props.expenses"
      :refresh-key="refreshKey"
      @expenses-updated="refreshKey++"
    />
    
    

ドーナツグラフのリアルタイム更新

問題

  • 以前はDashboard.vuereloadExpenesを呼ぶとrefreshKeyが更新され、DoughnutChart.vuerefresh-keyが変わってグラフが再描画されていた。
  • 今はExpenseList.vue内でreloadExpensesを呼んでいるため、Dashboard.vue側のrefreshKeyが変わらず、グラフが更新されない。

解決策

  1. ExpenseList.vueでデータ更新完了時にexpenses-updatedイベントをemit
  2. Dashboard.vueでイベントを受け取り、refreshKey++

Composableを使う基準

  • ロジックが100行以上になりそうな場合
  • watchcomputedなどVue特有のHooksを複数使う時
  • ユニットテストを書きたい時

まとめ

  • Composableでロジックを分離すると、再利用性・保守性・テスト性が向上する
  • フォームや一覧表示などの複雑な処理は、Composableとコンポーネントを分けて管理するとスッキリ
  • emitを活用して、親コンポーネントとリアルタイムで連携できる
2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?