コンポーネント分割
現在の App.vue は機能が十分に揃っていますが、すべてのコードが一箇所に詰め込まれています。
次のステップではコンポーネント分割を行います。以下の内容を学べます:
- Tailwind v4 環境で再利用性の高い子コンポーネントを作成する方法。
- defineProps と defineEmits を使用して JS コンポーネント間でデータを渡す方法。
では、src/componentsディレクトリに以下の4つのファイルを作成します:
TaskHeader.vue(純粋な表示)TaskInput.vue(入力ロジック)TaskFilter.vue(状態切り替え)TaskItem.vue(単一タスク)

TaskHeader.vue —— 静的表示コンポーネント
このコンポーネントは JS ロジックを一切必要とせず、単に UI を表示します。
Tailwind v4 では、新しいグラデーション構文を引き続き使用します。
実例
<template>
<header class="bg-linear-to-br from-blue-600 to-indigo-700 p-8 text-white">
<h1 class="text-3xl font-black tracking-tight">TaskHub</h1>
<p class="text-blue-100/80 text-sm">Vue 3 + Tailwind v4 実践</p>
</header>
</template>
<header class="bg-linear-to-br from-blue-600 to-indigo-700 p-8 text-white">
<h1 class="text-3xl font-black tracking-tight">TaskHub</h1>
<p class="text-blue-100/80 text-sm">Vue 3 + Tailwind v4 実践</p>
</header>
</template>
TaskInput.vue —— defineEmits を使用してデータを送信
このコンポーネントはユーザー入力の取得を担当します。ユーザーが「追加」をクリックしたとき、データを直接変更せず、emit で親コンポーネントに通知します。
実例
<script setup>
import { ref } from 'vue';
// コンポーネントが外部に発行するイベント名を定義
const emit = defineEmits(['add-task']);
const title = ref('');
const handleSubmit = () => {
const value = title.value.trim();
if (!value) return;
// イベントを送信し、データを引数として渡す
emit('add-task', value);
title.value = '';
};
</script>
<template>
<div class="flex gap-2 mb-8">
<input
v-model="title"
@keyup.enter="handleSubmit"
placeholder="今日は何をやりますか?"
class="flex-1 bg-slate-50 border-none rounded-2xl px-4 py-3 focus:ring-2 focus:ring-brand/50 outline-none transition-all"
/>
<button @click="handleSubmit" class="bg-brand text-white px-6 rounded-2xl font-bold hover:scale-105 active:scale-95 transition-all">
+
</button>
</div>
</template>
import { ref } from 'vue';
// コンポーネントが外部に発行するイベント名を定義
const emit = defineEmits(['add-task']);
const title = ref('');
const handleSubmit = () => {
const value = title.value.trim();
if (!value) return;
// イベントを送信し、データを引数として渡す
emit('add-task', value);
title.value = '';
};
</script>
<template>
<div class="flex gap-2 mb-8">
<input
v-model="title"
@keyup.enter="handleSubmit"
placeholder="今日は何をやりますか?"
class="flex-1 bg-slate-50 border-none rounded-2xl px-4 py-3 focus:ring-2 focus:ring-brand/50 outline-none transition-all"
/>
<button @click="handleSubmit" class="bg-brand text-white px-6 rounded-2xl font-bold hover:scale-105 active:scale-95 transition-all">
+
</button>
</div>
</template>
TaskFilter.vue —— コンポーネント間の v-model 通信
Vue 3.4+ では、defineModel() を使用して双方向バインディングを実装することを推奨します。これは Tailwind v4 の UI 切り替えで非常に効率的です。
実例
<script setup>
// v-model を定義すると、親コンポーネントから渡された値が自動的に同期されます
const modelValue = defineModel();
const filters = ['all', 'active', 'completed'];
</script>
<template>
<div class="flex p-1 bg-slate-100 rounded-xl mb-6">
<button
v-for="f in filters"
:key="f"
@click="modelValue = f"
:class="[
'flex-1 py-1.5 text-xs font-bold rounded-lg transition-all capitalize',
modelValue === f ? 'bg-white text-brand shadow-sm' : 'text-slate-500'
]"
>
{{ f }}
</button>
</div>
</template>
// v-model を定義すると、親コンポーネントから渡された値が自動的に同期されます
const modelValue = defineModel();
const filters = ['all', 'active', 'completed'];
</script>
<template>
<div class="flex p-1 bg-slate-100 rounded-xl mb-6">
<button
v-for="f in filters"
:key="f"
@click="modelValue = f"
:class="[
'flex-1 py-1.5 text-xs font-bold rounded-lg transition-all capitalize',
modelValue === f ? 'bg-white text-brand shadow-sm' : 'text-slate-500'
]"
>
{{ f }}
</button>
</div>
</template>
TaskItem.vue —— defineProps を使用してデータを受信
これは最も中核となる子コンポーネントで、単一のタスクを表示し、変更・削除操作をフィードバックします。
実例
<script setup>
// 親コンポーネントから渡されたタスクオブジェクトを受け取る
defineProps({
task: {
type: Object,
required: true
}
});
// 親コンポーネントに通知するイベントを定義する
const emit = defineEmits(['toggle', 'remove']);
</script>
<template>
<li class="group flex items-center justify-between p-4 bg-slate-50 rounded-2xl border border-transparent hover:border-slate-200 hover:bg-white transition-all">
<div class="flex items-center gap-3">
<input
type="checkbox"
:checked="task.isCompleted"
@change="emit('toggle', task.id)"
class="w-5 h-5 accent-brand cursor-pointer"
/>
<span :class="['text-slate-700 font-medium', task.isCompleted ? 'line-through text-slate-400 opacity-50' : '']">
{{ task.title }}
</span>
</div>
<button @click="emit('remove', task.id)" class="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition-all p-1">
✕
</button>
</li>
</template>
// 親コンポーネントから渡されたタスクオブジェクトを受け取る
defineProps({
task: {
type: Object,
required: true
}
});
// 親コンポーネントに通知するイベントを定義する
const emit = defineEmits(['toggle', 'remove']);
</script>
<template>
<li class="group flex items-center justify-between p-4 bg-slate-50 rounded-2xl border border-transparent hover:border-slate-200 hover:bg-white transition-all">
<div class="flex items-center gap-3">
<input
type="checkbox"
:checked="task.isCompleted"
@change="emit('toggle', task.id)"
class="w-5 h-5 accent-brand cursor-pointer"
/>
<span :class="['text-slate-700 font-medium', task.isCompleted ? 'line-through text-slate-400 opacity-50' : '']">
{{ task.title }}
</span>
</div>
<button @click="emit('remove', task.id)" class="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition-all p-1">
✕
</button>
</li>
</template>
リファクタリング後の App.vue (ロジック中枢)
現在の App.vue はクリーンな指揮官となり、元データの管理と子コンポーネントから届く命令の処理だけを担当します。
実例
<script setup>
import { ref, computed } from 'vue';
// 子コンポーネントをインポート
import TaskHeader from './components/TaskHeader.vue';
import TaskInput from './components/TaskInput.vue';
import TaskFilter from './components/TaskFilter.vue';
import TaskItem from './components/TaskItem.vue';
// --- データ状態 ---
const tasks = ref([
{ id: '1', title: 'Tailwind v4 の新機能を体験', isCompleted: false },
{ id: '2', title: 'Composition API を習得する', isCompleted: true }
]);
const filter = ref('all');
// --- ロジック処理 ---
const handleAddTask = (title) => {
tasks.value.unshift({ id: crypto.randomUUID(), title, isCompleted: false });
};
const handleRemoveTask = (id) => {
tasks.value = tasks.value.filter(t => t.id !== id);
};
const handleToggleTask = (id) => {
const task = tasks.value.find(t => t.id === id);
if (task) task.isCompleted = !task.isCompleted;
};
// --- 算出プロパティ ---
const filteredTasks = computed(() => {
if (filter.value === 'active') return tasks.value.filter(t => !t.isCompleted);
if (filter.value === 'completed') return tasks.value.filter(t => t.isCompleted);
return tasks.value;
});
</script>
<template>
<div class="min-h-screen py-12 px-4">
<div class="max-w-md mx-auto bg-white rounded-3xl shadow-xl shadow-slate-200 border border-slate-100 overflow-hidden">
<TaskHeader />
<main class="p-6">
<TaskInput @add-task="handleAddTask" />
<TaskFilter v-model="filter" />
<ul class="space-y-3">
<TransitionGroup name="list">
<TaskItem
v-for="task in filteredTasks"
:key="task.id"
:task="task"
@toggle="handleToggleTask"
@remove="handleRemoveTask"
/>
</TransitionGroup>
</ul>
<div v-if="filteredTasks.length === 0" class="text-center py-12 text-slate-400 text-sm">
関連するタスクはありません...
</div>
</main>
</div>
</div>
</template>
<style scoped>
.list-enter-active, .list-leave-active { transition: all 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28); }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
</style>
import { ref, computed } from 'vue';
// 子コンポーネントをインポート
import TaskHeader from './components/TaskHeader.vue';
import TaskInput from './components/TaskInput.vue';
import TaskFilter from './components/TaskFilter.vue';
import TaskItem from './components/TaskItem.vue';
// --- データ状態 ---
const tasks = ref([
{ id: '1', title: 'Tailwind v4 の新機能を体験', isCompleted: false },
{ id: '2', title: 'Composition API を習得する', isCompleted: true }
]);
const filter = ref('all');
// --- ロジック処理 ---
const handleAddTask = (title) => {
tasks.value.unshift({ id: crypto.randomUUID(), title, isCompleted: false });
};
const handleRemoveTask = (id) => {
tasks.value = tasks.value.filter(t => t.id !== id);
};
const handleToggleTask = (id) => {
const task = tasks.value.find(t => t.id === id);
if (task) task.isCompleted = !task.isCompleted;
};
// --- 算出プロパティ ---
const filteredTasks = computed(() => {
if (filter.value === 'active') return tasks.value.filter(t => !t.isCompleted);
if (filter.value === 'completed') return tasks.value.filter(t => t.isCompleted);
return tasks.value;
});
</script>
<template>
<div class="min-h-screen py-12 px-4">
<div class="max-w-md mx-auto bg-white rounded-3xl shadow-xl shadow-slate-200 border border-slate-100 overflow-hidden">
<TaskHeader />
<main class="p-6">
<TaskInput @add-task="handleAddTask" />
<TaskFilter v-model="filter" />
<ul class="space-y-3">
<TransitionGroup name="list">
<TaskItem
v-for="task in filteredTasks"
:key="task.id"
:task="task"
@toggle="handleToggleTask"
@remove="handleRemoveTask"
/>
</TransitionGroup>
</ul>
<div v-if="filteredTasks.length === 0" class="text-center py-12 text-slate-400 text-sm">
関連するタスクはありません...
</div>
</main>
</div>
</div>
</template>
<style scoped>
.list-enter-active, .list-leave-active { transition: all 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28); }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
</style>
核心知識ポイントのまとめ
defineProps(親から子へ):
TaskItemを通じてpropsタスクデータを受け取ります。Vue では、Props は読み取り専用の値です。子コンポーネントは決して直接変更すべきではありません。propsの値、これが「単方向データフロー」です。
defineEmits(子から親へ):
- 子コンポーネントがデータを変更する必要があるとき(削除やチェックなど)、それによって
emit信号を発し、親コンポーネントが実際の変更ロジックを実行します。これにより、データ変更の発信元が一つだけになり、デバッグが容易になります。
defineModel(双方向バインディングの神器):
- に
TaskFilterでは、Vue 3.4 で導入されたdefineModel。これにより、親子コンポーネント間の状態同期コードが大幅に簡素化され、手動で書いたりpropsリッスンしたりする必要はありません。@update:modelValue。
Tailwind v4 の再利用性:
- コンポーネントを分割しても、Tailwind の原子化クラス名は引き続き有効です。Vite プラグインが自動的にスキャンするのは、
src配下のすべての.vueファイルであり、対応するスタイルを生成します。