0.ねらい
Nuxt(Vue)でのコンポーザブルというワードは、他の言語ではなかなか耳にすることもない事もあり、導入にあたり、「そもそも何なのか」分かりづらい事も多いかと思います。
自分で実際にNuxtに触れた際、最初に感じたことは、Reactのフックの感触がかなり頼りだったこともあり、コンポーザブルの説明及びドキュメント作成時はReactの使い慣れたフック風のケースでも作ると、その理解が深まるのではないか と思い、作ってみることにしました。
※素手で執筆した事もあり、かなり誤字脱字を修正しました。(落ち着いたと思います。(2025/07/15/22:23)
この記事の対象者
- Reactには慣れているけれど、Vueには慣れていない方
- Vueのコンポーザブルというものがどういうものか感覚的に理解したい方
- Vueプロジェクトでの補助資料作成に迷われている方
※ついでにuseEffectを現場で人に説明する資料にもなるかも?
強調しますが、この記事のメインは、フローチャートや図を用いたコンポーザブルの理解と資料作成パターンの習得です
早速、まずは下準備としてReactのuseEffect風のコンポーザブルのサンプルコードを掲載します。
(読みづらかったら飛ばしてさらに下の、この記事のメインである図やチャートと見比べてみてください。)
念の為、更に理解が深まるように、後半かなりボリュームが増えてしまいましたが検証できるテストコードの例も掲載してみました。
1. コンポーザブルの実装: useEffect.ts
ふるまいはReactのuseEffect()です。フリー素材ですね。
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. 活用サンプル: データ取得と購読
<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. データフロー図
こちらはステート間の依存関係データとデータの流れる方向を示し、ユーザー操作からビュー更新までの一連の流れを表した図です。
かなりどういったものか感覚的に掴みやすくなるかと思います。
解説
このサンプルでは、Reactの useEffect
に似た機能を持つNuxtのコンポーザブルを実装し、実際のユースケースで活用しています。
-
useEffect コンポーザブル:
- コールバック関数と依存配列を受け取る
- マウント時、依存値の変更時に実行される
- クリーンアップ関数をサポート
-
活用例:
- ユーザーデータの取得 (userId依存)
- リアルタイム通知の購読 (userData.id依存)
- クリーンアップ処理による購読解除
このようなコンポーザブルを作成し、それをケースとすることで、Reactから移行するエンジニア・チームメンバーにとって馴染みやすいパターンを提供しながら、Vue/Nuxtのリアクティブな特性を活かした実装が可能になります。
どうしても実際に動かしてみたい!という方はテスト用サンプルコードの構成例をこの後のチャプターに掲載しましたので、それを参考に、作ってみてください。
あくまで本記事のメインはフローチャートなので、あまりクオリティは期待しないでください(笑)
(おまけの付録)useEffect風コンポーザブルのテスト用サンプルコード
以下に、実装したuseEffectコンポーザブルをテストするためのコード例を提供します。
ユーザーIDとユーザーデータに関連するテストシナリオを含みます。
1. テスト用APIモックの作成: 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
<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
<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
<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
<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>
テストコードの使い方
このテストコードでは:
-
/test-users-list
にアクセスすると、テスト用ユーザーの一覧を見ることができます。 -
/test-effect
にアクセスすると、ユーザーを切り替えながらuseEffectの動作をテストできます:- ユーザーIDを変更すると、UserProfileコンポーネントとUserNotificationsコンポーネントが両方新しいデータでアップデートされます
- 通知はリアルタイムでランダムに40%の確率で追加されます
- エラーケースもテストできます
-
コンポーネント内では:
- 各コンポーネントは独自のデータ取得と状態管理を行っています
- UserProfileではシンプルなデータ取得
- UserNotificationsでは購読パターンとクリーンアップ機能が実装されています
このテストコードを使うことで、実装したuseEffectコンポーザブルの動作と、依存配列によるエフェクトの再実行、クリーンアップ関数の挙動を確認できます。