0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Nuxtでのコンポーザブルの実装と資料作成をuseEffect風ケースで学んでみる

Last updated at Posted at 2025-07-15

0.ねらい

 Nuxt(Vue)でのコンポーザブルというワードは、他の言語ではなかなか耳にすることもない事もあり、導入にあたり、「そもそも何なのか」分かりづらい事も多いかと思います。
 自分で実際にNuxtに触れた際、最初に感じたことは、Reactのフックの感触がかなり頼りだったこともあり、コンポーザブルの説明及びドキュメント作成時はReactの使い慣れたフック風のケースでも作ると、その理解が深まるのではないか と思い、作ってみることにしました。

※素手で執筆した事もあり、かなり誤字脱字を修正しました。(落ち着いたと思います。(2025/07/15/22:23)

この記事の対象者

  • Reactには慣れているけれど、Vueには慣れていない方
  • Vueのコンポーザブルというものがどういうものか感覚的に理解したい方
  • Vueプロジェクトでの補助資料作成に迷われている方
    ※ついでにuseEffectを現場で人に説明する資料にもなるかも?

強調しますが、この記事のメインは、フローチャートや図を用いたコンポーザブルの理解と資料作成パターンの習得です
早速、まずは下準備としてReactのuseEffect風のコンポーザブルのサンプルコードを掲載します。
(読みづらかったら飛ばしてさらに下の、この記事のメインである図やチャートと見比べてみてください。)

 念の為、更に理解が深まるように、後半かなりボリュームが増えてしまいましたが検証できるテストコードの例も掲載してみました。

1. コンポーザブルの実装: useEffect.ts

ふるまいはReactのuseEffect()です。フリー素材ですね。

composables/useEffect.ts
import { watch, onMounted, onUnmounted, Ref, WatchSource } from 'vue'

export function useEffect(
  effectCallback: () => void | (() => void), 
  dependencies?: WatchSource | WatchSource[]
) {
  // マウント時に実行
  let cleanup: void | (() => void)
  
  onMounted(() => {
    cleanup = effectCallback()
  })
  
  // 依存配列が指定されている場合は変更時に実行
  if (dependencies) {
    watch(dependencies, () => {
      // 前回のクリーンアップ関数があれば実行
      if (typeof cleanup === 'function') {
        cleanup()
      }
      // 新しいエフェクトを実行
      cleanup = effectCallback()
    }, { deep: true })
  }
  
  // アンマウント時にクリーンアップ
  onUnmounted(() => {
    if (typeof cleanup === 'function') {
      cleanup()
    }
  })
}

2. 活用サンプル: データ取得と購読

vue:pages/user-dashboard.vue
<template>
  <div>
    <h1>ユーザーダッシュボード</h1>
    <div v-if="loading">読み込み中...</div>
    <div v-else-if="error">エラー: {{ error }}</div>
    <div v-else>
      <h2>ユーザー情報</h2>
      <p>ID: {{ userData.id }}</p>
      <p>名前: {{ userData.name }}</p>
      
      <h2>最新通知 ({{ notifications.length }})</h2>
      <ul>
        <li v-for="notification in notifications" :key="notification.id">
          {{ notification.message }}
        </li>
      </ul>
      
      <button @click="userId++">次のユーザーを表示</button>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useEffect } from '~/composables/useEffect'

// ステート管理
const userId = ref(1)
const userData = reactive({
  id: null,
  name: ''
})
const notifications = ref([])
const loading = ref(false)
const error = ref(null)

// ユーザーデータ取得のエフェクト
useEffect(() => {
  loading.value = true
  error.value = null
  
  // ユーザーデータ取得
  fetchUserData(userId.value)
    .then(data => {
      userData.id = data.id
      userData.name = data.name
      loading.value = false
    })
    .catch(err => {
      error.value = err.message
      loading.value = false
    })
  
  // クリーンアップ関数は不要なのでreturnなし
}, [userId]) // userId が変わるたびに実行

// 通知購読のエフェクト
useEffect(() => {
  if (!userData.id) return
  
  loading.value = true
  // 通知サービス購読開始
  const unsubscribe = subscribeToNotifications(userData.id, (newNotifications) => {
    notifications.value = newNotifications
    loading.value = false
  })
  
  // クリーンアップ関数: 購読解除
  return () => {
    unsubscribe()
    notifications.value = []
  }
}, [userData.id]) // userData.id が変わるたびに実行

// APIモック
function fetchUserData(id) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id, name: `ユーザー${id}` })
    }, 500)
  })
}

function subscribeToNotifications(userId, callback) {
  const interval = setInterval(() => {
    const newNotification = {
      id: Date.now(),
      message: `ユーザー${userId}への新しい通知: ${new Date().toLocaleTimeString()}`
    }
    callback([...notifications.value, newNotification].slice(-5)) // 最新5件を保持
  }, 3000)
  
  return () => clearInterval(interval)
}
</script>

3. ステート管理フローチャート

 左から順に、

  • コンポーネントのライフサイクル
  • エフェクト実行タイミング
  • クリーンアップ関数の実行フロー
    の順番でステート管理フローチャートを紹介します。

4. データフロー図

 こちらはステート間の依存関係データとデータの流れる方向を示し、ユーザー操作からビュー更新までの一連の流れを表した図です。
 かなりどういったものか感覚的に掴みやすくなるかと思います。

データフロー図.jpg


解説

このサンプルでは、Reactの useEffect に似た機能を持つNuxtのコンポーザブルを実装し、実際のユースケースで活用しています。

  1. useEffect コンポーザブル:

    • コールバック関数と依存配列を受け取る
    • マウント時、依存値の変更時に実行される
    • クリーンアップ関数をサポート
  2. 活用例:

    • ユーザーデータの取得 (userId依存)
    • リアルタイム通知の購読 (userData.id依存)
    • クリーンアップ処理による購読解除

このようなコンポーザブルを作成し、それをケースとすることで、Reactから移行するエンジニア・チームメンバーにとって馴染みやすいパターンを提供しながら、Vue/Nuxtのリアクティブな特性を活かした実装が可能になります。

どうしても実際に動かしてみたい!という方はテスト用サンプルコードの構成例をこの後のチャプターに掲載しましたので、それを参考に、作ってみてください。
あくまで本記事のメインはフローチャートなので、あまりクオリティは期待しないでください(笑)

(おまけの付録)useEffect風コンポーザブルのテスト用サンプルコード

以下に、実装したuseEffectコンポーザブルをテストするためのコード例を提供します。
ユーザーIDとユーザーデータに関連するテストシナリオを含みます。

1. テスト用APIモックの作成: mocks/api.ts

mocks/api.ts

const USERS_DATA = {
  1: { id: 1, name: "佐藤太郎", email: "taro@example.com", role: "admin" },
  2: { id: 2, name: "鈴木花子", email: "hanako@example.com", role: "user" },
  3: { id: 3, name: "田中一郎", email: "ichiro@example.com", role: "user" },
  4: { id: 4, name: "山田優子", email: "yuko@example.com", role: "manager" }
};

export const NOTIFICATIONS = {
  1: [
    { id: 101, message: "管理者権限が付与されました", read: false },
    { id: 102, message: "新しいダッシュボード機能をご確認ください", read: false }
  ],
  2: [
    { id: 201, message: "パスワードの更新をお願いします", read: true },
    { id: 202, message: "新しいプロジェクトに招待されました", read: false }
  ],
  3: [
    { id: 301, message: "プロフィールを更新してください", read: false }
  ],
  4: [
    { id: 401, message: "チームメンバーの承認をお願いします", read: false },
    { id: 402, message: "レポートが完成しました", read: true },
    { id: 403, message: "会議が30分後に開始します", read: false }
  ]
};

// 遅延を模したAPI
export function fetchUserData(userId: number) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId in USERS_DATA) {
        resolve(USERS_DATA[userId]);
      } else {
        reject(new Error(`User ID ${userId} not found`));
      }
    }, 600);
  });
}

// 通知購読のモック
export function subscribeToNotifications(userId: number, callback: (notifications: any[]) => void) {
  let notificationsData = NOTIFICATIONS[userId] || [];
  
  // 初期データをコールバック
  setTimeout(() => {
    callback([...notificationsData]);
  }, 300);
  
  // 定期的に新しい通知を追加
  const intervalId = setInterval(() => {
    if (Math.random() > 0.6) { // 40%の確率で通知追加
      const newNotification = {
        id: Date.now(),
        message: `ユーザー${userId}への新しい通知: ${new Date().toLocaleTimeString()}`,
        read: false
      };
      notificationsData = [...notificationsData, newNotification];
      callback([...notificationsData]);
    }
  }, 3000);
  
  // アンサブスクライブ関数を返す
  return () => {
    clearInterval(intervalId);
    console.log(`通知購読を解除しました: ユーザーID ${userId}`);
  };
}

2. ユーザー情報表示コンポーネント: components/UserProfile.vue

components/UserProfile.vue
<template>
  <div class="user-profile">
    <div v-if="loading" class="loading">
      <p>ユーザー情報を読み込み中...</p>
    </div>
    <div v-else-if="error" class="error">
      <p>エラーが発生しました: {{ error }}</p>
      <button @click="retryFetch">再試行</button>
    </div>
    <div v-else class="profile-content">
      <h2>{{ userData.name }}</h2>
      <div class="profile-details">
        <p><strong>ID:</strong> {{ userData.id }}</p>
        <p><strong>メール:</strong> {{ userData.email }}</p>
        <p><strong>権限:</strong> {{ userData.role }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue';
import { useEffect } from '~/composables/useEffect';
import { fetchUserData } from '~/mocks/api';

const props = defineProps({
  userId: {
    type: Number,
    required: true
  }
});

const userData = reactive({
  id: null,
  name: '',
  email: '',
  role: ''
});
const loading = ref(false);
const error = ref(null);

const fetchUser = () => {
  loading.value = true;
  error.value = null;
  
  fetchUserData(props.userId)
    .then(data => {
      Object.assign(userData, data);
      loading.value = false;
    })
    .catch(err => {
      error.value = err.message;
      loading.value = false;
    });
};

const retryFetch = () => {
  fetchUser();
};

// useEffectを使ってユーザーIDが変わるたびにデータ取得
useEffect(() => {
  fetchUser();
  // このエフェクトはクリーンアップ不要
}, () => props.userId);
</script>

<style scoped>
.user-profile {
  padding: 1rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  max-width: 500px;
}
.loading, .error {
  padding: 2rem;
  text-align: center;
}
.error {
  color: red;
}
.profile-content {
  padding: 0.5rem;
}
.profile-details {
  margin-top: 1rem;
}
</style>

3. 通知リストコンポーネント: components/UserNotifications.vue

components/UserNotifications.vue
<template>
  <div class="notifications-panel">
    <h3>通知 ({{ notifications.length }})</h3>
    <div v-if="loading && !notifications.length" class="loading">
      通知を読み込み中...
    </div>
    <div v-else-if="!notifications.length" class="no-notifications">
      通知はありません
    </div>
    <ul v-else class="notifications-list">
      <li 
        v-for="notification in notifications" 
        :key="notification.id"
        :class="{ unread: !notification.read }"
      >
        <span class="message">{{ notification.message }}</span>
        <span v-if="!notification.read" class="badge">新着</span>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useEffect } from '~/composables/useEffect';
import { subscribeToNotifications } from '~/mocks/api';

const props = defineProps({
  userId: {
    type: Number,
    required: true
  }
});

const notifications = ref([]);
const loading = ref(false);

// useEffectを使って通知の購読と解除
useEffect(() => {
  if (!props.userId) return;
  
  loading.value = true;
  console.log(`ユーザーID ${props.userId} の通知を購読開始...`);
  
  // 通知サービス購読
  const unsubscribe = subscribeToNotifications(props.userId, (newNotifications) => {
    notifications.value = newNotifications;
    loading.value = false;
  });
  
  // クリーンアップ関数 - 購読解除
  return () => {
    unsubscribe();
    notifications.value = [];
  };
}, () => props.userId);
</script>

<style scoped>
.notifications-panel {
  padding: 1rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  max-width: 500px;
  margin-top: 1rem;
}
.loading, .no-notifications {
  padding: 1rem;
  text-align: center;
  color: #666;
}
.notifications-list {
  list-style: none;
  padding: 0;
}
.notifications-list li {
  padding: 0.75rem;
  border-bottom: 1px solid #eee;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.notifications-list li:last-child {
  border-bottom: none;
}
.unread {
  background-color: #f0f7ff;
}
.badge {
  background-color: #ff4757;
  color: white;
  font-size: 0.7rem;
  padding: 0.2rem 0.5rem;
  border-radius: 10px;
}
</style>

4. テストページ: pages/test-effect.vue

pages/test-effect.vue

<template>
  <div class="test-container">
    <h1>useEffect テスト</h1>
    
    <div class="controls">
      <button @click="changeUser(-1)" :disabled="userId <= 1">前のユーザー</button>
      <span class="user-selector">ユーザーID: {{ userId }}</span>
      <button @click="changeUser(1)" :disabled="userId >= 4">次のユーザー</button>
    </div>
    
    <div class="test-error-controls">
      <button @click="setInvalidUser">存在しないユーザーID(エラーテスト)</button>
      <button @click="resetUser">リセット (ID:1)</button>
    </div>
    
    <div class="components-container">
      <UserProfile :userId="userId" />
      <UserNotifications :userId="userId" />
    </div>
    
    <div class="effect-explanation">
      <h3>useEffect動作説明</h3>
      <p>
        1. UserProfileコンポーネントでは、userIdが変更されるたびにuseEffectが実行され、
           新しいユーザーデータが取得されます。
      </p>
      <p>
        2. UserNotificationsコンポーネントでは、userIdが変更されると:
          <br>- 前のユーザーの通知購読がクリーンアップ関数によって解除されます
          <br>- 新しいユーザーの通知を購読開始します
      </p>
      <p>
        3. 「存在しないユーザー」ボタンをクリックすると、エラー処理の動作を確認できます
      </p>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import UserProfile from '~/components/UserProfile.vue';
import UserNotifications from '~/components/UserNotifications.vue';

const userId = ref(1);

const changeUser = (delta) => {
  const newId = userId.value + delta;
  if (newId >= 1 && newId <= 4) {
    userId.value = newId;
  }
};

const setInvalidUser = () => {
  userId.value = 99; // 存在しないID
};

const resetUser = () => {
  userId.value = 1;
};
</script>

<style scoped>
.test-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 1rem;
}
.controls {
  display: flex;
  align-items: center;
  margin-bottom: 1rem;
  gap: 1rem;
}
.user-selector {
  font-weight: bold;
}
.test-error-controls {
  margin-bottom: 1rem;
}
.components-container {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  margin-bottom: 2rem;
}
.effect-explanation {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 8px;
  margin-top: 2rem;
}
button {
  background-color: #3498db;
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
}
button:hover {
  background-color: #2980b9;
}
button:disabled {
  background-color: #bdc3c7;
  cursor: not-allowed;
}
button:not(:last-child) {
  margin-right: 0.5rem;
}
</style>

5. テスト用のデータとユーザーリスト: pages/test-users-list.vue

pages/test-users-list.vue
<template>
  <div class="users-test-page">
    <h1>テスト用ユーザー一覧</h1>
    <p class="description">
      これらのユーザーIDを使って、useEffectテストページでユーザー情報と通知の取得をテストできます。
    </p>
    
    <table class="users-table">
      <thead>
        <tr>
          <th>ユーザーID</th>
          <th>名前</th>
          <th>メール</th>
          <th>権限</th>
          <th>通知数</th>
          <th>アクション</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in users" :key="user.id">
          <td>{{ user.id }}</td>
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td>{{ user.role }}</td>
          <td>{{ (notifications[user.id] || []).length }}</td>
          <td>
            <NuxtLink :to="`/test-effect?userId=${user.id}`" class="view-button">
              表示する
            </NuxtLink>
          </td>
        </tr>
      </tbody>
    </table>
    
    <div class="navigation">
      <NuxtLink to="/test-effect" class="nav-button">テストページへ</NuxtLink>
    </div>
  </div>
</template>

<script setup>
import { USERS_DATA, NOTIFICATIONS } from '~/mocks/api';

// オブジェクトから配列に変換
const users = Object.values(USERS_DATA);
const notifications = NOTIFICATIONS;
</script>

<style scoped>
.users-test-page {
  max-width: 900px;
  margin: 0 auto;
  padding: 1rem;
}
.description {
  margin-bottom: 2rem;
  color: #555;
}
.users-table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 2rem;
}
.users-table th, .users-table td {
  padding: 0.75rem;
  text-align: left;
  border-bottom: 1px solid #ddd;
}
.users-table th {
  background-color: #f8f9fa;
  font-weight: bold;
}
.view-button {
  background-color: #27ae60;
  color: white;
  text-decoration: none;
  padding: 0.4rem 0.8rem;
  border-radius: 4px;
  font-size: 0.9rem;
}
.view-button:hover {
  background-color: #2ecc71;
}
.navigation {
  margin-top: 2rem;
  display: flex;
  justify-content: center;
}
.nav-button {
  background-color: #3498db;
  color: white;
  text-decoration: none;
  padding: 0.7rem 1.5rem;
  border-radius: 4px;
  font-weight: bold;
}
.nav-button:hover {
  background-color: #2980b9;
}
</style>

テストコードの使い方

このテストコードでは:

  1. /test-users-list にアクセスすると、テスト用ユーザーの一覧を見ることができます。

  2. /test-effect にアクセスすると、ユーザーを切り替えながらuseEffectの動作をテストできます:

    • ユーザーIDを変更すると、UserProfileコンポーネントとUserNotificationsコンポーネントが両方新しいデータでアップデートされます
    • 通知はリアルタイムでランダムに40%の確率で追加されます
    • エラーケースもテストできます
  3. コンポーネント内では:

    • 各コンポーネントは独自のデータ取得と状態管理を行っています
    • UserProfileではシンプルなデータ取得
    • UserNotificationsでは購読パターンとクリーンアップ機能が実装されています

このテストコードを使うことで、実装したuseEffectコンポーザブルの動作と、依存配列によるエフェクトの再実行、クリーンアップ関数の挙動を確認できます。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?