はじめに
Laravel + Vueで家計簿アプリを開発している中で、初めて**Composerble(コンポーザブル)**を導入した時の備忘録です。
フォーム処理や一覧表示、グラフ描画とのリアルタイム連携など、実装時に学んだことをまとめます。
Composable(コンポーザブル)とは?
VueのComposition APIでロジックを関数として切り出して再利用可能にする仕組みです。
特徴
- ロジックを
useXxxx.jsというファイルに分離して管理 - Vueの
ref,'reactive()', 'watch()'などそのまま使える - 複数コンポーネントで共通機能を共有・再利用できる
Composable導入ステップ
-
composablesフォルダを作成
慣習的に/js/composablesディレクトリを作ります/js /components /composables useExpenseForm.js # Composableファイル - 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 }; } - コンポーネントで利用する
<!-- 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 + 名詞」**で命名します。
例:
useExpenseFormuseChartDatausePaginationuseUserAuth
一覧表示をコンポーネント化
フォームだけでなく、一覧表示もコンポーネントとして分離しました。
-
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> -
Dashboard.vueで読み込み<ExpenseList :initial-expenses="props.expenses" :refresh-key="refreshKey" @expenses-updated="refreshKey++" />
ドーナツグラフのリアルタイム更新
問題
- 以前は
Dashboard.vueでreloadExpenesを呼ぶとrefreshKeyが更新され、DoughnutChart.vueのrefresh-keyが変わってグラフが再描画されていた。 - 今は
ExpenseList.vue内でreloadExpensesを呼んでいるため、Dashboard.vue側のrefreshKeyが変わらず、グラフが更新されない。
解決策
-
ExpenseList.vueでデータ更新完了時にexpenses-updatedイベントをemit -
Dashboard.vueでイベントを受け取り、refreshKey++
Composableを使う基準
- ロジックが100行以上になりそうな場合
-
watchやcomputedなどVue特有のHooksを複数使う時 - ユニットテストを書きたい時
まとめ
- Composableでロジックを分離すると、再利用性・保守性・テスト性が向上する
- フォームや一覧表示などの複雑な処理は、Composableとコンポーネントを分けて管理するとスッキリ
-
emitを活用して、親コンポーネントとリアルタイムで連携できる