この記事は Angular - Qiita Advent Calendar 2025 - Qiita の19日目の記事になります
本日は @tommy1038 が担当します
はじめに
この記事では、Angular v21のSignal Formsを使ってTODOアプリを段階的に実装していきます
今年から初めてAngularを触り始めたのですが
公式チュートリアルは一通り終えたものの
「実際のアプリケーションで、これらの知識をどう組み合わせて使えばいいのか?」
という疑問にぶつかりました
この記事は、そんな当時の自分に向けて、チュートリアルと実務の橋渡しとなることを目指して書いてみようと思います
この記事の対象読者
- Angularの公式チュートリアルは完了したが、次に何を作ればいいかわからない方
- Signalの使い方をより理解したい方
- チュートリアルと実務の橋渡しとなる例が欲しい方
この記事で学べること
- Signalsを使った状態管理の使い方
- Computed Signalsによる派生状態の管理
-
input()/output()を使った親子コンポーネント間の通信 -
Control Flow(
@for,@if)の活用方法 - Local Storageを使ったデータの永続化
完成イメージ
以下の機能を持つTODOアプリを作成します:
- TODOの追加(リストの上に追加)
- TODOの完了/未完了の切り替え(取り消し線で表示)
- TODOの削除
- フィルター機能(全て/未完了/完了済み)
- Local Storageへの自動保存
使用技術スタック
- Angular v21 - 最新のAngularフレームワーク
- Signals - リアクティブな状態管理
- Standalone Components - モジュール不要の新しいコンポーネント形式
- Tailwind CSS - ユーティリティファーストのスタイリング
- Bun - JavaScriptランタイム
TODOアプリについて
公式チュートリアルでは個別の機能(コンポーネント、データバインディング、サービスなど)を学びますが、それらをどう組み合わせて実際のアプリを作るかは別の課題になるかなと思います
TODOアプリは、シンプルながらも多くの重要な概念を含んでいるため、学習用のプロジェクトとして参考になります
Angularの主要機能
- 状態管理 - Signalsによるリアクティブな状態
- コンポーネント設計 - 責務の分離と再利用性
- イベント処理 - 親子間通信とイベントの伝播
- データ永続化 - Local Storageとの連携
環境構築とプロジェクト準備
プロジェクトの作成
bun install -g @angular/cli
ng version
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI : 21.0.3
Node.js : 24.12.0
Package Manager : npm 11.6.2
Operating System : darwin arm64sh
# Bunを使ってAngularプロジェクトを作成
ng new angular-todo
# プロジェクトに移動
cd angular-todo
セットアップ時の選択肢:
- Which stylesheet system would you like to use?: Tailwind CSS
- Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)?: No
- Which AI tools do you want to configure with Angular best practices? https://angular.dev/ai/develop-with-ai: Claude
AIに関しては、せっかくなので普段使っている Claudeを選択しました
プロジェクト構造
最終的なファイル構成は以下の通りです:
❯ tree src/app/
src/app/
├── app.config.ts
├── app.css
├── app.html
├── app.routes.ts
├── app.spec.ts
├── app.ts
├── components
│ ├── todo-filter
│ │ └── todo-filter.ts
│ ├── todo-form
│ │ └── todo-form.ts
│ ├── todo-item
│ │ └── todo-item.ts
│ └── todo-list
│ └── todo-list.ts
├── models
│ └── todo.model.ts
└── services
└── todo-storage.service.ts
8 directories, 12 files
これから段階的にこれらのファイルを作成していきます
Phase 1: データモデルと基本的な一覧表示
最初のステップでは、TODOのデータモデルを定義し、ハードコードされたダミーデータを表示します
このPhaseで学ぶこと
- TypeScriptのinterface定義
- Signalsによる状態管理の基礎
- Standalone Componentの構造
- Control Flow(
@for,@empty)
データモデルの作成
まず、TODOの型定義を作成します
src/app/models/todo.model.ts(新規作成)
export interface Todo {
id: string; // 一意識別子(UUID)
title: string; // TODOのタイトル
completed: boolean; // 完了状態
createdAt: number; // 作成日時(タイムスタンプ)
}
export type FilterType = 'all' | 'active' | 'completed';
TodoItemコンポーネントの作成
個別のTODO項目を表示するコンポーネントを作成します
src/app/components/todo-item/todo-item.ts(新規作成)
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { Todo } from '../../models/todo.model';
@Component({
selector: 'app-todo-item',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex items-center gap-3 p-3 border-b">
<span>{{ todo().title }}</span>
</div>
`
})
export class TodoItem {
readonly todo = input.required<Todo>();
}
要点の整理
1. Standalone Component
@Component({
selector: 'app-todo-item',
imports: [], // 必要な依存関係をここに記述
// ...
})
Angular v20+では、standalone: trueはデフォルトなので記述不要です。imports配列に必要な依存関係を記述します
2. input()関数
readonly todo = input.required<Todo>();
従来の@Input()デコレーターの代わりに、input()関数を使います
-
input.required<T>()で必須のinputを定義 - テンプレートでは
todo()のように関数呼び出しでアクセス - Signalベースのため、リアクティブに変更を検知
3. OnPush変更検知戦略
changeDetection: ChangeDetectionStrategy.OnPush
すべてのコンポーネントでOnPushを使用します。Signalと組み合わせることで、パフォーマンスが最適化されます
TodoListコンポーネントの作成
TODO項目のリストを表示するコンポーネントです
src/app/components/todo-list/todo-list.ts(新規作成)
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { Todo } from '../../models/todo.model';
import { TodoItem } from '../todo-item/todo-item';
@Component({
selector: 'app-todo-list',
imports: [TodoItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="mt-4">
@for (todo of todos(); track todo.id) {
<app-todo-item [todo]="todo" />
} @empty {
<p class="text-gray-400 text-center p-4">
No todos yet. Add one to get started!
</p>
}
</div>
`
})
export class TodoList {
readonly todos = input.required<Todo[]>();
}
要点の整理
1. importsによる依存解決
imports: [TodoItem],
使用するコンポーネントをimports配列に追加します。NgModuleは不要です
2. @for 制御フロー
@for (todo of todos(); track todo.id) {
<app-todo-item [todo]="todo" />
} @empty {
<p>No todos yet. Add one to get started!</p>
}
従来の*ngForの代わりに、Control Flowの@forを使います
-
trackは必須です。一意のキー(ここでは
todo.id)を指定します -
@emptyでリストが空の場合の表示を定義できます -
todos()のように関数呼び出しでSignalの値にアクセスします
注意点
trackを忘れると、Angular開発サーバーで警告が出ます。パフォーマンス最適化のため、必ず一意のキーを指定しましょう
メインコンポーネントの更新
アプリケーションのルートコンポーネントを更新します
src/app/app.ts
import { Component, signal, ChangeDetectionStrategy } from '@angular/core';
import { TodoList } from './components/todo-list/todo-list';
import { Todo } from './models/todo.model';
@Component({
selector: 'app-root',
imports: [TodoList],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
// Phase 1: ハードコードされたダミーデータ
protected readonly todos = signal<Todo[]>([
{
id: '1',
title: 'ゴミ出し',
completed: false,
createdAt: Date.now()
},
{
id: '2',
title: '掃除',
completed: true,
createdAt: Date.now()
},
{
id: '3',
title: '洗濯',
completed: false,
createdAt: Date.now()
}
]);
}
要点の整理
1. signal()による状態管理
protected readonly todos = signal<Todo[]>([...]);
-
signal<T>(初期値)でSignalsを作成 -
protectedにすることで、テンプレートからはアクセス可能、外部からは不可 -
readonlyにすることで、再代入を防ぐ
Signal の利点:
- 状態の変更を自動検知し、関連するコンポーネントだけを更新
- レンダリング更新を最適化
- OnPushコンポーネントと自然に統合
2. protected修飾子
テンプレートからアクセスするプロパティやメソッドにはprotectedを使います。外部に公開する必要がないため、publicは使いません
src/app/app.html
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">TODO App</h1>
<app-todo-list [todos]="todos()" />
</main>
3. Signalsの値を取得する
<app-todo-list [todos]="todos()" />
-
todos()のように関数呼び出しでSignalsの値を取得 - プロパティバインディング
[]で子コンポーネントに渡す
Phase 1の動作確認
bun start
ブラウザでhttp://localhost:4200を開くと、3つのダミーTODOが表示されます
Phase 1のまとめ
このPhaseで実装したこと:
- Todoモデルの定義
- Signalsによる状態管理
- コンポーネントの階層構造(App → TodoList → TodoItem)
-
@for制御フローによるリスト表示
Phase 2: TODO追加機能
次に、新しいTODOを追加する機能を実装します
このPhaseで学ぶこと
- Signal Formsによるフォーム実装
-
form()とsignal()を組み合わせた状態管理 -
Fieldディレクティブによる入力バインディング - output()によるイベント送信
- signal.update()による状態更新
Signal Formsとは?
Signal Formsは、Angular v21で導入された新しいフォーム管理機能です
従来のアプローチとの違い:
| 項目 | FormsModule | Signal Forms |
|---|---|---|
| インポート | FormsModule |
Fieldディレクティブ |
| バインディング | [(ngModel)] |
[field] |
| 状態管理 | 通常のプロパティ | signal() |
| バリデーション | HTML属性 |
form()の第2引数 |
| 値の取得 | this.title |
this.todoModel().title |
Signal Formsの利点:
- 状態の自動同期: フォームとデータモデルが自動で双方向同期
- 完全な型安全性: UIコントロールとデータモデル間で型安全
- バリデーション一元化: 検証ルールをスキーマで一箇所に定義
TodoFormコンポーネントの作成
src/app/components/todo-form/todo-form.ts(新規作成)
import { Component, ChangeDetectionStrategy, output, signal } from '@angular/core';
import { form, Field, required } from '@angular/forms/signals';
interface TodoFormData {
title: string;
}
@Component({
selector: 'app-todo-form',
imports: [Field],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (submit)="onSubmit($event)" class="mb-6">
<div class="flex gap-2">
<input
[field]="todoForm.title"
type="text"
placeholder="Add a new todo..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
[disabled]="todoForm.title().invalid()"
>
Add
</button>
</div>
</form>
`
})
export class TodoForm {
readonly todoAdded = output<string>();
readonly todoModel = signal<TodoFormData>({
title: ''
});
protected readonly todoForm = form(this.todoModel, (schemaPath) => {
required(schemaPath.title, { message: 'Title is required' });
});
protected onSubmit(event: Event): void {
event.preventDefault();
const title = this.todoModel().title.trim();
if (title && this.todoForm.title().valid()) {
this.todoAdded.emit(title);
// リセット
this.todoModel.set({ title: '' });
}
}
}
要点の整理
1. Signal Formsのインポート
import { form, Field, required } from '@angular/forms/signals';
-
form: フォームオブジェクトを作成する関数 -
Field: 入力フィールドをバインドするディレクティブ -
required: 必須バリデーター
2. フォームデータの型定義
interface TodoFormData {
title: string;
}
TypeScriptの型安全性を活用するため、フォームデータの型を定義します
3. signalによるモデル定義
readonly todoModel = signal<TodoFormData>({
title: ''
});
-
signal()でフォームのデータモデルを作成 - 初期値として空の
titleを設定 - Signalsなので、値の変更がリアクティブに追跡される
4. form()によるフォーム作成
protected readonly todoForm = form(this.todoModel, (schemaPath) => {
required(schemaPath.title, { message: 'Title is required' });
});
- 第1引数: データモデル(
todoModel) - 第2引数: バリデーション関数
-
schemaPath.titleでtitleフィールドを指定 -
required()で必須バリデーションを設定
5. テンプレートでのField使用
<input [field]="todoForm.title" />
-
[field]ディレクティブで入力フィールドとフォームをバインド -
todoForm.titleでtitleフィールドにアクセス - 双方向バインディングは自動的に行われる
6. バリデーション状態の確認
[disabled]="todoForm.title().invalid()"
-
todoForm.title()はSignalsなので関数呼び出し -
.invalid()でバリデーション状態を取得 - 無効な場合はボタンを無効化
7. 値の取得とリセット
// 値の取得
const title = this.todoModel().title;
// リセット
this.todoModel.set({ title: '' });
-
todoModel()で現在の値を取得 -
.set()で新しい値をセット
注意点: Signalの関数呼び出しを忘れる
// ❌ 間違い: Signalsを関数として呼び出していない
[disabled]="todoForm.title.invalid()"
// ✅ 正しい: Signalsは関数呼び出しが必要
[disabled]="todoForm.title().invalid()"
Signal Forms では、フィールドの値を読み取るときは関数呼び出しが必要です
メインコンポーネントの更新
src/app/app.tsに追加
import { Component, signal, ChangeDetectionStrategy } from '@angular/core';
import { TodoList } from './components/todo-list/todo-list';
import { TodoForm } from './components/todo-form/todo-form'; // 追加
import { Todo } from './models/todo.model';
@Component({
selector: 'app-root',
imports: [TodoList, TodoForm], // TodoFormを追加
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
protected readonly todos = signal<Todo[]>([]);
// Phase 2: TODO追加機能
protected addTodo(title: string): void {
const newTodo: Todo = {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: Date.now()
};
// 新しいTODOをリストの先頭に追加
this.todos.update(todos => [newTodo, ...todos]);
}
}
要点の整理
1. crypto.randomUUID()
id: crypto.randomUUID(),
ブラウザネイティブのUUID生成APIを使用します。外部ライブラリは不要です
2. signal.update()による状態更新
this.todos.update(todos => [newTodo, ...todos]);
Signalの値を更新する際は、update()メソッドを使います
- 引数に現在の値を受け取る関数を渡す
- 関数は新しい値を返す
- スプレッド構文
...で既存の配列をコピーし、先頭に新要素を追加
注意点: set()とupdate()の使い分け
// set(): 新しい値で完全に置き換える
this.todos.set([newTodo]);
// update(): 現在の値を元に新しい値を計算
this.todos.update(todos => [newTodo, ...todos]);
既存の値を使って更新する場合はupdate()を使います。
3. 不変更新(Immutable Update)
[newTodo, ...todos] // ✅ 新しい配列を作成
元の配列を直接変更せず、新しい配列を作成します。これにより:
- 変更検知が正しく動作
- バグの原因となる副作用を防ぐ
- 将来のデバッグが容易
src/app/app.htmlに追加
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">TODO App</h1>
<app-todo-form (todoAdded)="addTodo($event)" />
<app-todo-list [todos]="todos()" />
</main>
要点の整理
親子コンポーネント通信
<app-todo-form (todoAdded)="addTodo($event)" />
-
()はイベントバインディング - 子コンポーネント(TodoForm)が
todoAddedイベントを発火 - 親コンポーネント(App)の
addTodo()メソッドが呼ばれる -
$eventにはemitされた値(タイトル文字列)が入る
Phase 2の動作確認
bun start
フォームに入力してAddボタンを押すと、TODOがリストの上に追加されます。
Phase 2のまとめ
このPhaseで実装したこと:
- Signal Formsによるフォーム実装
- output()によるイベント送信
- signal.update()による不変更新
- 親子コンポーネント間の通信
Phase 3: 完了切り替えと削除機能
TODOの完了状態を切り替える機能と、削除機能を追加します
このPhaseで学ぶこと
- イベント伝播
- 配列の不変更新パターン(map, filter)
- 条件付きクラスバインディング
- outputイベントの連鎖
TodoItemコンポーネントの更新
src/app/components/todo-item/todo-item.ts
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import { Todo } from '../../models/todo.model';
@Component({
selector: 'app-todo-item',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex items-center gap-3 p-3 border-b hover:bg-gray-50">
<input
type="checkbox"
[checked]="todo().completed"
(change)="toggled.emit()"
class="w-5 h-5 text-blue-500 rounded cursor-pointer"
/>
<span
[class.line-through]="todo().completed"
[class.text-gray-400]="todo().completed"
class="flex-1"
>
{{ todo().title }}
</span>
<button
(click)="deleted.emit()"
class="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
>
Delete
</button>
</div>
`
})
export class TodoItem {
readonly todo = input.required<Todo>();
readonly toggled = output<void>();
readonly deleted = output<void>();
}
要点の整理
1. チェックボックスの実装
<input
type="checkbox"
[checked]="todo().completed"
(change)="toggled.emit()"
/>
-
[checked]でプロパティバインディング(一方向) -
(change)でchangeイベントをリッスン -
toggled.emit()で親コンポーネントに通知
2. 条件付きクラスバインディング
[class.line-through]="todo().completed"
[class.text-gray-400]="todo().completed"
-
[class.クラス名]="条件"で条件付きでクラスを適用 - 複数のクラスバインディングを同時に使用可能
3. void型のoutput
readonly toggled = output<void>();
readonly deleted = output<void>();
イベント自体が重要で、値を渡す必要がない場合はvoid型を使います
TodoListコンポーネントの更新
src/app/components/todo-list/todo-list.ts
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import { Todo } from '../../models/todo.model';
import { TodoItem } from '../todo-item/todo-item';
@Component({
selector: 'app-todo-list',
imports: [TodoItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul class="mt-4" role="list" aria-label="Todo list">
@for (todo of todos(); track todo.id) {
<li role="listitem">
<app-todo-item
[todo]="todo"
(toggled)="todoToggled.emit(todo.id)"
(deleted)="todoDeleted.emit(todo.id)"
/>
</li>
} @empty {
<li role="listitem">
<p class="text-gray-400 text-center p-4">No todos yet</p>
</li>
}
</ul>
`
})
export class TodoList {
readonly todos = input.required<Todo[]>();
readonly todoToggled = output<string>();
readonly todoDeleted = output<string>();
}
要点の整理
イベント連鎖(Event Chain)
(toggled)="todoToggled.emit(todo.id)"
(deleted)="todoDeleted.emit(todo.id)"
TodoItem → TodoList → App と段階的にイベントを伝播させます:
-
TodoItem: チェックボックスがクリックされる →
toggled.emit() -
TodoList:
toggledイベントを受け取る →todoToggled.emit(todo.id)でIDを付けて再発火 -
App:
todoToggledイベントを受け取る → 実際の状態更新処理
このパターンにより、各コンポーネントの責務が明確になります:
- TodoItem: 表示のみ
- TodoList: リスト管理
- App: 状態管理
メインコンポーネントの更新
src/app/app.tsに追加
export class App {
protected readonly todos = signal<Todo[]>([]);
protected addTodo(title: string): void {
// ... (Phase 2と同じ)
}
// Phase 3: 完了切り替えと削除機能
protected toggleTodo(id: string): void {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
protected deleteTodo(id: string): void {
this.todos.update(todos => todos.filter(todo => todo.id !== id));
}
}
ポイント解説
1. map()による完了切り替え
this.todos.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
-
map()で全要素を走査 - 対象のIDのTODOだけ、
completedを反転した新しいオブジェクトを作成 - スプレッド構文
{ ...todo }で既存プロパティをコピー - 他のTODOはそのまま返す
不変更新の重要性
// ❌ 悪い例: 直接変更
todo.completed = !todo.completed;
// ✅ 良い例: 新しいオブジェクトを作成
{ ...todo, completed: !todo.completed }
2. filter()による削除
this.todos.update(todos => todos.filter(todo => todo.id !== id));
-
filter()で条件に合う要素だけの新しい配列を作成 - 指定IDのTODO以外を残す
src/app/app.htmlに追加
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">TODO App</h1>
<app-todo-form (todoAdded)="addTodo($event)" />
<app-todo-list
[todos]="todos()"
(todoToggled)="toggleTodo($event)"
(todoDeleted)="deleteTodo($event)"
/>
</main>
Phase 3の動作確認
bun start
- チェックボックスをクリックすると取り消し線が付く/消える
- DeleteボタンでTODOが削除される
Phase 3のまとめ
このPhaseで実装したこと:
- チェックボックスによる完了切り替え
- 条件付きクラスバインディング(取り消し線)
- 削除ボタン
- イベント連鎖による親子間通信
- map()とfilter()を使った不変更新
Phase 4: フィルター機能
全て/未完了/完了済みでTODOをフィルタリングする機能を追加します
このPhaseで学ぶこと
- computed()による派生状態
- フィルターロジックの実装
- ボタンの状態管理
TodoFilterコンポーネントの作成
src/app/components/todo-filter/todo-filter.ts(新規作成)
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import { FilterType } from '../../models/todo.model';
@Component({
selector: 'app-todo-filter',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex gap-2 mb-4">
<button
(click)="filterChanged.emit('all')"
[class.bg-blue-500]="currentFilter() === 'all'"
[class.text-white]="currentFilter() === 'all'"
[class.bg-gray-200]="currentFilter() !== 'all'"
class="px-4 py-2 rounded-lg transition-colors"
>
All
</button>
<button
(click)="filterChanged.emit('active')"
[class.bg-blue-500]="currentFilter() === 'active'"
[class.text-white]="currentFilter() === 'active'"
[class.bg-gray-200]="currentFilter() !== 'active'"
class="px-4 py-2 rounded-lg transition-colors"
>
Active
</button>
<button
(click)="filterChanged.emit('completed')"
[class.bg-blue-500]="currentFilter() === 'completed'"
[class.text-white]="currentFilter() === 'completed'"
[class.bg-gray-200]="currentFilter() !== 'completed'"
class="px-4 py-2 rounded-lg transition-colors"
>
Completed
</button>
</div>
`
})
export class TodoFilter {
readonly currentFilter = input.required<FilterType>();
readonly filterChanged = output<FilterType>();
}
要点の整理
1. FilterType型の活用
readonly filterChanged = output<FilterType>();
Phase 1で定義したFilterType型を使うことで、型安全なフィルター切り替えを実現します
2. 複数のクラスバインディング
[class.bg-blue-500]="currentFilter() === 'all'"
[class.text-white]="currentFilter() === 'all'"
[class.bg-gray-200]="currentFilter() !== 'all'"
- 同じ要素に複数のクラスバインディングを適用可能
- アクティブなボタンは青色、非アクティブは灰色
メインコンポーネントの更新
src/app/app.tsに追加
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { TodoList } from './components/todo-list/todo-list';
import { TodoForm } from './components/todo-form/todo-form';
import { TodoFilter } from './components/todo-filter/todo-filter'; // 追加
import { Todo, FilterType } from './models/todo.model';
@Component({
selector: 'app-root',
imports: [TodoList, TodoForm, TodoFilter], // TodoFilterを追加
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
protected readonly todos = signal<Todo[]>([]);
// Phase 4: フィルター機能
protected readonly filter = signal<FilterType>('all');
protected readonly filteredTodos = computed(() => {
const allTodos = this.todos();
const currentFilter = this.filter();
switch (currentFilter) {
case 'active':
return allTodos.filter(todo => !todo.completed);
case 'completed':
return allTodos.filter(todo => todo.completed);
default:
return allTodos;
}
});
// ... (addTodo, toggleTodo, deleteTodo)
protected setFilter(newFilter: FilterType): void {
this.filter.set(newFilter);
}
}
要点の整理
1. computed()による派生状態
protected readonly filteredTodos = computed(() => {
const allTodos = this.todos();
const currentFilter = this.filter();
switch (currentFilter) {
// ... フィルタリングロジック
}
});
computed()は依存するSignalが変更されたときに自動的に再計算されます
-
todos()かfilter()が変更されると、filteredTodosも自動更新 - 結果はキャッシュされ、依存が変わらない限り再計算されない
- パフォーマンスが自動的に最適化される
computed()とsignalの違い
// signal: 値を保持し、set/updateで更新
protected readonly todos = signal<Todo[]>([]);
this.todos.update(...);
// computed: 他のSignalから派生、自動計算
// computed signalは直接更新できない(読み取り専用)
protected readonly filteredTodos = computed(() => {
return this.todos().filter(...);
});
2. switch文によるフィルタリング
switch (currentFilter) {
case 'active':
return allTodos.filter(todo => !todo.completed);
case 'completed':
return allTodos.filter(todo => todo.completed);
default:
return allTodos;
}
フィルタータイプに応じて、適切にフィルタリングした配列を返します
src/app/app.htmlに追加
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">TODO App</h1>
<app-todo-form (todoAdded)="addTodo($event)" />
<app-todo-filter
[currentFilter]="filter()"
(filterChanged)="setFilter($event)"
/>
<app-todo-list
[todos]="filteredTodos()"
(todoToggled)="toggleTodo($event)"
(todoDeleted)="deleteTodo($event)"
/>
</main>
要点の整理
<app-todo-list [todos]="filteredTodos()" />
todos()の代わりにfilteredTodos()を渡すことで、フィルタリングされたTODOのみが表示されます
Phase 4の動作確認
bun start
- Allボタン: すべてのTODOを表示
- Activeボタン: 未完了のTODOのみ表示
- Completedボタン: 完了済みのTODOのみ表示
All
Active
Completed
Phase 4のまとめ
このPhaseで実装したこと:
- フィルターコンポーネント
- computed()による派生状態の管理
- switch文によるフィルタリングロジック
Phase 5: Local Storage永続化
ブラウザのLocal StorageにTODOを保存し、リロード後もデータを保持できるようにします
このPhaseで学ぶこと
- サービスによる責務分離
- inject()による依存注入
- Local StorageのAPI
TodoStorageServiceの作成
src/app/services/todo-storage.service.ts(新規作成)
import { Injectable } from '@angular/core';
import { Todo } from '../models/todo.model';
@Injectable({
providedIn: 'root'
})
export class TodoStorageService {
readonly #STORAGE_KEY = 'angular_todos';
loadTodos(): Todo[] {
// SSR対応: window が存在しない環境では空配列を返す
if (typeof window === 'undefined') {
return [];
}
try {
const data = localStorage.getItem(this.#STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Failed to load todos from localStorage:', error);
return [];
}
}
saveTodos(todos: Todo[]): void {
// SSR対応: window が存在しない環境では何もしない
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(this.#STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Failed to save todos to localStorage:', error);
}
}
}
要点の整理
1. @Injectable デコレーター
@Injectable({
providedIn: 'root'
})
-
providedIn: 'root'でアプリケーション全体でシングルトンとして提供 - NgModuleに登録する必要なし(自動的にDIコンテナに登録される)
2. SSR(Server-Side Rendering)対応
if (typeof window === 'undefined') {
return [];
}
サーバーサイドレンダリング環境ではwindowやlocalStorageが存在しないため、チェックが必要です
3. エラーハンドリング
try {
localStorage.setItem(this.#STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Failed to save todos to localStorage:', error);
}
- Local Storageの容量制限やプライベートブラウジングモードでエラーが発生する可能性がある
- try-catchで適切にエラーハンドリング
4. JSON.stringify/parse
// 保存時
localStorage.setItem(this.#STORAGE_KEY, JSON.stringify(todos));
// 読み込み時
JSON.parse(data)
Local Storageは文字列しか保存できないため、オブジェクトをJSON文字列に変換します
メインコンポーネントの更新
src/app/app.tsに追加
import { Component, signal, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { TodoList } from './components/todo-list/todo-list';
import { TodoForm } from './components/todo-form/todo-form';
import { TodoFilter } from './components/todo-filter/todo-filter';
import { Todo, FilterType } from './models/todo.model';
import { TodoStorageService } from './services/todo-storage.service'; // 追加
@Component({
selector: 'app-root',
imports: [TodoList, TodoForm, TodoFilter],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
// Phase 5: Local Storage永続化
readonly #storageService = inject(TodoStorageService);
// Local Storageから読み込み(なければ空配列)
protected readonly todos = signal<Todo[]>(this.#storageService.loadTodos());
protected readonly filter = signal<FilterType>('all');
protected readonly filteredTodos = computed(() => {
// ... (Phase 4と同じ)
});
protected addTodo(title: string): void {
const newTodo: Todo = {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: Date.now()
};
this.todos.update(todos => [newTodo, ...todos]);
this.saveTodos(); // 追加後に保存
}
protected toggleTodo(id: string): void {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
this.saveTodos(); // 更新後に保存
}
protected deleteTodo(id: string): void {
this.todos.update(todos => todos.filter(todo => todo.id !== id));
this.saveTodos(); // 削除後に保存
}
protected setFilter(newFilter: FilterType): void {
this.filter.set(newFilter);
}
// Phase 5: Local Storage保存
private saveTodos(): void {
this.#storageService.saveTodos(this.todos());
}
}
要点の整理
1. inject()による依存注入
readonly #storageService = inject(TodoStorageService);
従来のコンストラクタインジェクションの代わりに、inject()関数を使います。
従来の方法との比較
// ❌ 古い方法: コンストラクタインジェクション
constructor(private storageService: TodoStorageService) {}
// ✅ 新しい方法: inject()関数
readonly #storageService = inject(TodoStorageService);
2. 初期化時にLocal Storageから読み込み
protected readonly todos = signal<Todo[]>(this.#storageService.loadTodos());
Signalsの初期値としてLocal Storageから読み込んだデータを設定します。
3. 変更時に自動保存
protected addTodo(title: string): void {
// ... TODO追加処理
this.saveTodos(); // 追加後に自動保存
}
すべての変更操作(追加/更新/削除)の後にsaveTodos()を呼び出します
Phase 5の動作確認
bun start
- TODOを追加
- ブラウザをリロード
- データが保持されていることを確認
開発者ツールで確認:
- ブラウザの開発者ツールを開く
- Application → Local Storage → http://localhost:4200
-
angular_todosキーにJSON形式でデータが保存されている
Phase 5のまとめ
このPhaseで実装したこと:
- TodoStorageServiceの作成
- inject()による依存注入
- Local Storageへの保存/読み込み
まとめ
おつかれさまでした!
5つのPhaseを通じて、Angular v21の実践的なTODOアプリを完成させました
この記事が、Angularの理解の一助となれば幸いです
明日は、@noxi515さんです!







