RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

Vue 3 Composablesで肥大化コンポーネントをスリムにした話

はじめに

RevCommの熊谷です。どうぞよろしくお願いします!

Vue 3のComposition API、使ってますか〜?便利ですよね。 もう、Compositionのない世界には戻れない...

今回は、実際のプロジェクトで使っているuseListというComposableを例に、Composablesの使い方をシェアしてみますね。

困ってたこと

紹介するのは、MiiTel Adminという管理画面での話です。MiiTel Adminは、自社サービス「MiiTel(ミーテル)」を支える管理画面で、Vue 3 + Nuxt 4 + TypeScriptで作られたWebアプリケーション。会社の成長とともに機能が増え続け、現在は84ページの大規模な管理画面になっています(ちょっと整理したいとは思ってる...!)。

それぞれのページは、一覧・編集・削除がセットになっているパターンで統一されています。

そんなMiiTel Adminですが、ページ数が増えてくると、同じようなロジックがあちこちに散らばる問題が出てきちゃってました。

  • 似たような処理が 各ページでコピペ されてる
  • 1つのコンポーネントが 500行、1000行超え...
  • UIとロジックが混ざってて、テストが書きづらい

たとえば、ユーザー一覧ページはこんな感じになってました:

<script lang="ts" setup>
// 色々な関数で使うrefがごちゃ混ぜ
const users = ref([])
const filteredUsers = ref([])
const currentPage = ref(1)
const searchValue = ref('')
const total = computed(() => filteredUsers.value.length)
const isEditModalOpen = ref(false)
const editingUser = ref(null)

// データ取得
const fetchUsers = async () => { /* ... */ }

// データ変換
const mapUsers = (response) => { /* ... */ }

// ページング
const movePage = (page) => { /* ... */ }

// 登録・編集関連
const openEditModal = () => { /* ... */ }

// 削除関連
const deleteUser = async (id) => { /* ... */ }

// その他、大量の処理がずらーーーっと...
</script>

こういうコードが、いろんな一覧ページで繰り返されてたんです...😭

もともとVue 2で作ってて、リアクティブな状態を持つ共通機能を再利用するにはMixinしかなかったんですよね。でもMixinは色々と使い勝手が悪かったこともあり、結局コピペで対応してました。

で、Vue 3の移行が完全に完了したタイミングで、Composablesを導入することにしたんです!

Vue 2からVue 3への移行は決して楽じゃなかったけど、その先にはComposablesによる美しく整理された世界が待っていました✨

Composablesってなに?

簡単に言うと、Vue 3のComposition APIを使ってロジックをまとめて再利用できるようにした関数のこと。Reactのhooksみたいな感じですね。私たちは、再利用性とロジックのカプセル化を目的に導入しました。(詳しくはVue公式ドキュメント

実装イメージはこんな感じ:

Before: 機能ごとのコードがrefcomputedfunctionsに散らばってごちゃごちゃ...

After: 各機能が独立したComposableに整理されてスッキリ〜

こうすると、こんないいことがありました:

  • コードの見通しが良くなる: 機能ごとにファイルが分かれて、どこに何があるか一目瞭然
  • バグが減る: refを機能間で共有しないから、意図しない副作用が起きにくい
  • 開発スピードが上がる: 共通ロジックを再利用できて、同じコードを何度も書かなくていい
  • テストが書きやすい: ロジックが独立してるから、単体テストがシンプルに

そして何より、各開発者が自分の担当ロジックに集中できるようになったのが大きいです。レビューも「このComposableは何をしてるか」が明確だから、ポイントを絞って確認できるようになりました✨

useListを作ってみた

さて、じゃあ具体的にどうやったかというと...

MiiTel Adminには一覧ページがたくさんあるんですが、どのページも「データ取得」「ページネーション」「検索・フィルタリング」みたいな共通機能が必要なんですよね。これを毎回書くのは大変だし、同じようなバグも生まれやすい。

そこで、これらの共通機能をまとめたuseListというComposableを作ることにしました!

export interface UseListOptions<T, U> {
  mapFunction: (list: U[]) => T[];
  // ...
}

export interface UseListResponse<T, U> {
  listData: DeepReadonly<Ref<T[]>>;
  total: ComputedRef<number>;
  movePage: (page: number) => void;
  // ...
}

export const useList = <T, U>(options: UseListOptions<T, U>): UseListResponse<T, U> => {
  const listData: Ref<T[]> = ref([]);
  const currentPage = ref(1);

  const movePage = (page: number) => { /* ... */ };

  return {
    listData: readonly(listData),
    currentPage: readonly(currentPage),
    total,
    movePage,
    // ...
  };
};

設計で気をつけたこと

ジェネリクスで型安全に

export const useList = <T, U>({ mapFunction, filterFunction, pageSize }: UseListOptions<T, U>)
  • U: APIから取得した生のデータ型
  • T: UIで使うために変換後のデータ型

useList<User, ApiUser>useList<UserGroup, ApiUserGroup>みたいに、同じComposableをいろんな型で使えます。TypeScript最高!

mapFunctionを外から渡す

汎用的なロジックと個別のロジックを分けられるし、テストもしやすくなりました。

readonlyで守る

listData: readonly(listData),
currentPage: readonly(currentPage),

外から勝手に変更されるのを防いで、バグを減らせます。安心✨

1つのComposableに機能を詰め込みすぎない

いわゆる単一責任の原則ですね。例えば、ポーリング機能が欲しくなったとき、useListに追加するのではなく、別のComposableとして作って組み合わせるようにしてます。

// ❌ useListにポーリング機能を追加してしまう
const { listData, startPolling } = useList({ polling: true, ... });

// ✅ 別のComposableとして作って組み合わせる
const { listData, ... } = withListPolling(useList({ ... }));

小さく作って組み合わせる方が、テストしやすいし、必要な機能だけ使えるので便利です!

実際に使ってみる

ページ固有のComposableを作る

useListをベースにして、ユーザー一覧用のComposableを作りますね。

export const useUserList = (): UseUserListResponse => {
  const base = useList<UserListItem, ApiUser>({
    mapFunction: mapUsers,
  });

  const initializeUsers = async () => {
    const list = await fetch(/** ... */);
    base.setData(list);
  };

  return { 
    ...base,
    initializeUsers 
  };
};

// 純粋関数として切り出し、テストしやすく。
export const mapUsers = (response: ApiUser[]): UserListItem[] => { /** ... */};

return...baseを返すことで、useListの機能をそのまま公開しつつ、ページ固有の処理を追加しています。

コンポーネントで使う

<template>
  <div>
    <ListUser :data-source="listData" :page="currentPage" />
  </div>
</template>

<script lang="ts" setup>
const { listData, currentPage, initializeUsers } = useUserList();
const { /* ... */ } = useUserEdit();
const { /* ... */ } = useUserDelete();

await initializeUsers();
</script>

コンポーネント側がめっちゃスッキリしましたよね?

他のページにも同じパターンを

このパターンを他のページにも展開していきます。

共通のComposable(useListuseEdituseDelete)をベースに、各ページ固有のComposableを作っていく。

ベースとなるComposableを使うことで、関数名や変数名も自然と統一されて、プロジェクト全体の保守性がぐっと上がりました。

AIも使って横展開

でも、これを84ページ全部に適用するのって結構大変そうじゃないですか? ...というわけで、もちろんAIをフル活用してます!

お手本ページの作り込み

まずはお手本となるページを1つ、チーム全員で徹底的に磨き上げます。ペアプロでみんなの意見を聞いて、コード品質・可読性・保守性にこだわってます。(昨日のペアプロの記事もぜひ見てね)

AIで横展開

お手本ができたら、AIに他のページを作ってもらいます:

  1. お手本ページのコードをAIに見せる
  2. 「このパターンで〇〇ページを作って」とお願い
  3. AIが作ったコードをレビュー&微調整

お手本の設計にチームの知恵を集める → AIで展開 → 人がレビュー、という流れで、品質とスピードを両立できてます。AIを使うからこそ、最初の設計が大事なんですよね〜。

おわりに

Composablesの実践例をまとめた記事があんまりなかったので、書いてみました。

誰かの参考になれば嬉しいです〜