はじめに
こんにちは!この記事では、Vue 3のComposition APIとTypeScriptを使ってTODOアプリを作成する方法を解説します。
近年、Vue開発ではOptions APIからComposition APIへのシフトが進み、<script setup>構文を使った宣言的な開発が主流となっています。本記事では、実際に動作するTODOアプリを通じて、Vue 3 Composition APIの基本的な使い方を学んでいきます。
記事の目的・対象読者
目的
- Vue 3 Composition APIの基本的な使い方を理解する
- ref、computed、onMountedの実践的な活用方法を学ぶ
- Composable関数を使った状態管理パターンを習得する
- Feature-Sliced Designの基本概念を理解する
対象読者
- Vue/TypeScriptの基本文法を理解している方
- Composition APIの概念は知っているが実践経験が少ない方
- コンポーネント設計のベストプラクティスを学びたい方
ソースコード
プロジェクト構成
ディレクトリ構造と概要
src/
├── components/ # 共有UI要素
│ ├── Button/ # ボタンコンポーネント
│ ├── Input/ # 入力フィールド
│ └── Checkbox/ # チェックボックス
├── composables/ # 共有Composable関数
│ └── useTasks.ts # タスク管理Composable
├── features/
│ └── todos/ # TodoFeature
│ ├── components/ # Feature固有コンポーネント
│ │ ├── TaskForm/ # タスク入力フォーム
│ │ ├── TaskItem/ # タスクアイテム
│ │ ├── FilterButtons/ # フィルタボタン群
│ │ └── TaskCounter/ # タスクカウンター
│ ├── TodoApp/ # Featureコンテナ
│ └── types.ts # Feature固有型定義
├── types/ # グローバル型定義
├── App.vue # アプリケーションルート
└── main.ts # エントリーポイント
Feature-Sliced Design
本プロジェクトはFeature-Sliced Designを採用しています。
| 階層 | 役割 | 例 |
|---|---|---|
|
共有コンポーネント (components/) |
複数のFeatureで再利用される基本UI要素 | Button, Input, Checkbox |
|
Composables (composables/) |
複数のFeatureで再利用されるロジック | useTasks |
|
Feature固有コンポーネント (features/todos/components/) |
Todo機能専用のコンポーネント | TaskForm, TaskItem, FilterButtons |
|
Featureコンテナ (features/todos/) |
Feature全体を統合 | TodoApp |
なぜFeature-Sliced Designなのか?
機能(Feature)ごとにコードをまとめることで、関連するコンポーネント・ロジック・型定義が同じディレクトリに配置され、保守性が向上します。新機能追加時も影響範囲が明確で、スケーラビリティに優れています。一方、複数Featureで共有される基本UI要素はcomponents/に、ロジックはcomposables/に配置し、再利用性を確保しています。
コンポーネント構成図
Button、Input、Checkboxといった 共有UI要素が、複数のFeature固有コンポーネント から利用されていることがわかります。
アプリの基本フロー
TODO追加から表示までの流れ
ユーザーがタスクを追加してから画面に表示されるまでの一連の流れを見ていきます。
主要な処理の流れ
-
入力受付:
Inputコンポーネントでユーザー入力を受け取る(v-model) - バリデーション: 空文字列でないことを確認
- タスク生成: 一意のID(タイムスタンプ)を持つTaskオブジェクトを作成
-
状態更新:
refで管理されているtasks配列に追加 - 永続化: localStorageに保存
- フィルタリング: 現在のフィルタ条件(すべて/未完了/完了済み)に応じて表示
- レンダリング: Vueが自動的に画面を更新
refとリアクティビティ
Vueのrefで状態を更新すると、その値を参照しているコンポーネントが自動的に再レンダリングされます。これにより、最新のタスク一覧が自動的に画面に反映されます。手動でDOMを操作する必要はありません。
実装
1. 型定義
アプリケーションで使用する型は、グローバル型とFeature固有型に分けて定義します。
グローバル型定義(共有UI要素用)
// 汎用UIコンポーネントの型定義
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
}
export interface InputProps {
modelValue: string;
placeholder?: string;
}
export interface CheckboxProps {
modelValue: boolean;
}
v-modelとは?
Vue 3では、v-modelを使って双方向データバインディングを実現します。コンポーネントでmodelValueプロップを受け取り、update:modelValueイベントを発行することで、親コンポーネントとの双方向通信が可能になります。
Feature固有型定義(Todo機能用)
// TODO機能固有の型定義
// タスクの型定義
export interface Task {
id: number; // 一意のID(タイムスタンプ)
text: string; // タスクの内容
completed: boolean; // 完了状態
createdAt: Date; // 作成日時
}
// フィルタタイプ(すべて/未完了/完了済み)
export type FilterType = 'all' | 'active' | 'completed';
// 各コンポーネントのProps型定義
export interface TaskFormProps {
onAddTask: (text: string) => void;
}
export interface TaskItemProps {
task: Task;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
export interface FilterButtonsProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
export interface TaskCounterProps {
activeCount: number;
onClearCompleted: () => void;
}
2. Composable関数:useTasks
タスクの状態管理を行うComposable関数です。このComposableがアプリケーションの中核となります。
Composableとは?
Vue 3のComposition API機能(ref、computed、onMountedなど)を組み合わせて、共通のロジックを再利用できる仕組みです。useTasksはタスクの追加・削除・保存・フィルタリングなどのロジックをまとめており、UIコンポーネントから分離することで可読性と再利用性を高めています。ReactのカスタムフックとComposableは、ロジックの再利用という点で同じ役割を果たします。
import { ref, computed, onMounted } from 'vue';
import { Task, FilterType } from '../features/todos/types';
export const useTasks = () => {
// タスクリストの状態管理
const tasks = ref<Task[]>([]);
// 現在のフィルタ状態管理
const currentFilter = ref<FilterType>('all');
// 初回マウント時:localStorageからタスクを読み込む
onMounted(() => {
const savedTasks = localStorage.getItem('tasks');
if (savedTasks) {
const parsedTasks = JSON.parse(savedTasks).map((task: any) => ({
...task,
createdAt: new Date(task.createdAt), // Date型に変換
}));
tasks.value = parsedTasks;
}
});
// タスクを保存する共通関数
const saveTasks = (updatedTasks: Task[]) => {
localStorage.setItem('tasks', JSON.stringify(updatedTasks));
tasks.value = updatedTasks;
};
// タスク追加
const addTask = (text: string) => {
const newTask: Task = {
id: Date.now(), // 現在のタイムスタンプをIDとして使用
text,
completed: false,
createdAt: new Date(),
};
const updatedTasks = [...tasks.value, newTask];
saveTasks(updatedTasks);
};
// タスクの完了/未完了を切り替え
const toggleTask = (id: number) => {
const updatedTasks = tasks.value.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
);
saveTasks(updatedTasks);
};
// タスク削除
const deleteTask = (id: number) => {
const updatedTasks = tasks.value.filter(task => task.id !== id);
saveTasks(updatedTasks);
};
// 完了済みタスクを一括削除
const clearCompleted = () => {
const updatedTasks = tasks.value.filter(task => !task.completed);
saveTasks(updatedTasks);
};
// フィルタリングとソート(算出プロパティ)
const filteredTasks = computed(() => {
return tasks.value
.filter(task => {
if (currentFilter.value === 'active') return !task.completed;
if (currentFilter.value === 'completed') return task.completed;
return true; // 'all'の場合
})
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); // 作成日時の昇順
});
// 未完了タスクの数をカウント(算出プロパティ)
const activeTaskCount = computed(() => {
return tasks.value.filter(task => !task.completed).length;
});
// フィルタ変更
const setCurrentFilter = (filter: FilterType) => {
currentFilter.value = filter;
};
// Composableから返す値
return {
tasks: filteredTasks,
currentFilter,
activeTaskCount,
addTask,
toggleTask,
deleteTask,
clearCompleted,
setCurrentFilter,
};
};
computedとは?
computedは、依存する値が変更されたときに自動的に再計算される算出プロパティです。上記のfilteredTasksは、tasks.valueやcurrentFilter.valueが変更されると自動的に再計算されます。キャッシュされるため、依存する値が変わらない限り再計算は行われず、パフォーマンスが最適化されます。
3. 共有UIコンポーネント
Button コンポーネント
<template>
<button
:class="buttonClass"
:disabled="disabled"
@click="handleClick"
>
<slot></slot>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { ButtonProps } from '../../types';
import styles from './Button.module.css';
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
disabled: false,
});
const emit = defineEmits<{
click: [];
}>();
const buttonClass = computed(() => {
return [styles.button, styles[props.variant]].filter(Boolean).join(' ');
});
const handleClick = () => {
emit('click');
};
</script>
slotとは?
<slot>はVueの強力な機能で、親コンポーネントから子コンポーネントにコンテンツを渡すことができます。Reactのchildrenプロップと同じ役割を果たします。上記のButtonコンポーネントでは、ボタンのテキストやアイコンなどを自由に挿入できるようになっています。
Input コンポーネント
<template>
<input
type="text"
:value="modelValue"
:placeholder="placeholder"
:class="styles.input"
@input="handleInput"
@keydown="handleKeyDown"
/>
</template>
<script setup lang="ts">
import { InputProps } from '../../types';
import styles from './Input.module.css';
withDefaults(defineProps<InputProps>(), {
placeholder: '',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
keypress: [key: string];
}>();
const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement;
emit('update:modelValue', target.value);
};
const handleKeyDown = (e: KeyboardEvent) => {
emit('keypress', e.key);
};
</script>
Checkbox コンポーネント
<template>
<input
type="checkbox"
:checked="modelValue"
:class="styles.checkbox"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { CheckboxProps } from '../../types';
import styles from './Checkbox.module.css';
defineProps<CheckboxProps>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const handleChange = (e: Event) => {
const target = e.target as HTMLInputElement;
emit('update:modelValue', target.checked);
};
</script>
4. Feature固有コンポーネント
TaskForm コンポーネント
<template>
<div :class="styles.taskForm">
<Input
v-model="taskText"
placeholder="新しいタスクを入力..."
@keypress="handleKeyPress"
/>
<Button @click="handleSubmit">
追加
</Button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Button from '../../../../components/Button/Button.vue';
import Input from '../../../../components/Input/Input.vue';
import { TaskFormProps } from '../../types';
import styles from './TaskForm.module.css';
const props = defineProps<TaskFormProps>();
const taskText = ref('');
const handleSubmit = () => {
const trimmedText = taskText.value.trim();
if (trimmedText !== '') {
props.onAddTask(trimmedText);
taskText.value = ''; // 入力フィールドをクリア
}
};
const handleKeyPress = (key: string) => {
if (key === 'Enter') {
handleSubmit();
}
};
</script>
v-modelの使い方
v-model="taskText"は、InputコンポーネントのmodelValueプロップとupdate:modelValueイベントを自動的にバインドします。これにより、入力フィールドの値がtaskTextと常に同期されます。
TaskItem コンポーネント
<template>
<li :class="[styles.taskItem, task.completed ? styles.completed : '']">
<Checkbox
:modelValue="task.completed"
@update:modelValue="handleToggle"
/>
<span :class="styles.taskText">{{ task.text }}</span>
<Button
variant="danger"
@click="handleDelete"
>
削除
</Button>
</li>
</template>
<script setup lang="ts">
import Button from '../../../../components/Button/Button.vue';
import Checkbox from '../../../../components/Checkbox/Checkbox.vue';
import { TaskItemProps } from '../../types';
import styles from './TaskItem.module.css';
const props = defineProps<TaskItemProps>();
const handleToggle = () => {
props.onToggle(props.task.id);
};
const handleDelete = () => {
props.onDelete(props.task.id);
};
</script>
FilterButtons コンポーネント
<template>
<div :class="styles.filterButtons">
<Button
v-for="{ key, label } in filters"
:key="key"
:variant="currentFilter === key ? 'primary' : 'secondary'"
@click="() => onFilterChange(key)"
>
{{ label }}
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '../../../../components/Button/Button.vue';
import { FilterButtonsProps, FilterType } from '../../types';
import styles from './FilterButtons.module.css';
defineProps<FilterButtonsProps>();
const filters: { key: FilterType; label: string }[] = [
{ key: 'all', label: 'すべて' },
{ key: 'active', label: '未完了' },
{ key: 'completed', label: '完了済み' },
];
</script>
v-forディレクティブ
v-forは、配列やオブジェクトをループして要素を生成するVueの構文です。:key属性を指定することで、Vueが各要素を一意に識別し、効率的にDOMを更新できるようになります。
TaskCounter コンポーネント
<template>
<div :class="styles.taskCounter">
<span :class="styles.count">{{ activeCount }} 個のタスク</span>
<Button
variant="secondary"
@click="onClearCompleted"
>
完了したタスクを削除
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '../../../../components/Button/Button.vue';
import { TaskCounterProps } from '../../types';
import styles from './TaskCounter.module.css';
defineProps<TaskCounterProps>();
</script>
5. Featureコンテナ
TodoApp コンポーネント
アプリケーション全体を統合する最上位コンポーネントです。
<template>
<div :class="styles.container">
<h1 :class="styles.title">To-Doリスト</h1>
<!-- タスク追加フォーム -->
<TaskForm :onAddTask="addTask" />
<!-- フィルタボタン -->
<FilterButtons
:currentFilter="currentFilter"
:onFilterChange="setCurrentFilter"
/>
<!-- タスクリスト -->
<ul :class="styles.taskList">
<li v-if="tasks.length === 0" :class="styles.emptyMessage">
タスクがありません
</li>
<TaskItem
v-else
v-for="task in tasks"
:key="task.id"
:task="task"
:onToggle="toggleTask"
:onDelete="deleteTask"
/>
</ul>
<!-- タスクカウンターと完了済み削除ボタン -->
<TaskCounter
:activeCount="activeTaskCount"
:onClearCompleted="clearCompleted"
/>
</div>
</template>
<script setup lang="ts">
import TaskForm from '../components/TaskForm/TaskForm.vue';
import FilterButtons from '../components/FilterButtons/FilterButtons.vue';
import TaskItem from '../components/TaskItem/TaskItem.vue';
import TaskCounter from '../components/TaskCounter/TaskCounter.vue';
import { useTasks } from '../../../composables/useTasks';
import styles from './TodoApp.module.css';
const {
tasks,
currentFilter,
activeTaskCount,
addTask,
toggleTask,
deleteTask,
clearCompleted,
setCurrentFilter,
} = useTasks();
</script>
<script setup>の利点
<script setup>構文を使うと、インポートしたコンポーネントや関数が自動的にテンプレートで利用可能になります。また、definePropsやdefineEmitsといったコンパイラマクロを使って、より簡潔にコンポーネントを定義できます。
6. App.vue
<template>
<TodoApp />
</template>
<script setup lang="ts">
import TodoApp from './features/todos/TodoApp/TodoApp.vue';
</script>
<style>
@import './App.css';
</style>
状態管理
状態管理とは?
「状態(state)」とは、アプリの中で変化するデータのことを指します。例えば、タスクの一覧やフィルタ状態などがそれにあたります。Vueではrefやcomputed、Composable関数を使ってこの状態を安全に管理します。
シーケンス図:タスク追加
ユーザーがタスクを追加する際のコンポーネント間のやり取りを示します。
Vueのリアクティブシステム
Vueはrefやreactiveで定義された値が変更されると、その値を使用しているすべてのコンポーネントを自動的に追跡し、必要な箇所のみを効率的に再レンダリングします。これにより、開発者は状態の変更に集中でき、DOMの更新はVueが自動的に処理します。
シーケンス図:タスク完了切り替え
タスクの完了/未完了を切り替える操作の流れです。
シーケンス図:フィルタリング
フィルタボタンをクリックした際の動作です。
重要なポイント
1. 単方向データフロー
Vueでは、データは常に親から子へpropsとして流れます。
TodoApp (状態を保持)
↓ props
↓ :task, :onToggle, :onDelete
↓
TaskItem (表示のみ)
子コンポーネントから親の状態を変更する場合は、親から渡されたコールバック関数を使用するか、イベントを発行します。
2. Props Down, Events Up
Vueの基本原則:
-
Props Down: 親から子へデータを渡す(
:task="task") -
Events Up: 子から親へイベントを通知する(
@click="handleClick")
3. 不変性(Immutability)
Vueの状態を更新する際は、元のオブジェクトを直接変更せず、新しいオブジェクトを作成します。
// ❌ 悪い例:元の配列を直接変更
tasks.value.push(newTask);
// ✅ 良い例:新しい配列を作成
tasks.value = [...tasks.value, newTask];
なぜ不変性が重要?
Vueのリアクティブシステムは、オブジェクトの参照の変更を検出して再レンダリングをトリガーします。配列のメソッド(push、popなど)で直接変更すると、リアクティブシステムが変更を検出できない場合があります。スプレッド構文を使って新しい配列を作成することで、確実に変更が検出されます。
4. onMountedによるライフサイクル管理
onMountedは、コンポーネントがマウントされた後に実行される処理を定義します。
onMounted(() => {
// 副作用:localStorageからの読み込み
const savedTasks = localStorage.getItem('tasks');
if (savedTasks) {
tasks.value = JSON.parse(savedTasks);
}
});
ReactとVueの比較
Composition APIとHooks
| 概念 | React | Vue 3 |
|---|---|---|
| 状態管理 | useState |
ref, reactive
|
| 副作用 | useEffect |
onMounted, onUpdated, watchEffect
|
| 算出値 | useMemo |
computed |
| カスタムロジック | Custom Hooks | Composables |
コンポーネント定義
React
const Button: React.FC<ButtonProps> = ({ children, onClick, variant }) => {
return <button onClick={onClick}>{children}</button>;
};
Vue
<template>
<button @click="handleClick">
<slot></slot>
</button>
</template>
<script setup lang="ts">
const props = defineProps<ButtonProps>();
const emit = defineEmits<{ click: [] }>();
</script>
状態管理の違い
React - useState
const [tasks, setTasks] = useState<Task[]>([]);
setTasks([...tasks, newTask]); // 新しい配列を設定
Vue - ref
const tasks = ref<Task[]>([]);
tasks.value = [...tasks.value, newTask]; // .valueで値にアクセス
イベント処理
React
<button onClick={() => handleClick()}>Click</button>
<input onChange={(e) => setValue(e.target.value)} />
Vue
<button @click="handleClick">Click</button>
<input @input="(e) => value = e.target.value" />
双方向バインディング
React
// 手動で双方向バインディングを実装
<input value={text} onChange={(e) => setText(e.target.value)} />
Vue
<!-- v-modelで自動的に双方向バインディング -->
<input v-model="text" />
まとめ
これまでに、Vue 3 Composition API、Composable関数、Feature-Sliced Designを使って、シンプルながら拡張性の高いTODOアプリを構築しました。本章では、学んだ内容を振り返り、どのようにプロジェクト設計や状態管理に応用できるかを整理します。
学んだこと
- ref: リアクティブな状態管理
- computed: 依存する値から自動的に算出される値の定義
- onMounted: コンポーネントのライフサイクルフック
- Composable関数: ロジックの再利用可能な形での抽出
- Feature-Sliced Design: 機能単位でのコード整理
- コンポーネント設計: 共有UI要素とFeature固有コンポーネントの分離
- Props & Events: 親から子への状態の受け渡しとイベントによる通知
- v-model: 双方向データバインディング
- Immutability: 状態更新時の不変性の維持