6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Angular v21 Signal Formsで TODOアプリを作ってみる

6
Last updated at Posted at 2025-12-18

この記事は 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への自動保存

Kapture 2025-12-17 at 03.07.55.gif

使用技術スタック

  • 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が表示されます

image.png

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.titletitleフィールドを指定
  • 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がリストの上に追加されます。

image.png

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 と段階的にイベントを伝播させます:

  1. TodoItem: チェックボックスがクリックされる → toggled.emit()
  2. TodoList: toggledイベントを受け取る → todoToggled.emit(todo.id)でIDを付けて再発火
  3. 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が削除される

image.png

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

image.png

Active

image.png

Completed

image.png

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 [];
}

サーバーサイドレンダリング環境ではwindowlocalStorageが存在しないため、チェックが必要です

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
  1. TODOを追加
  2. ブラウザをリロード
  3. データが保持されていることを確認

開発者ツールで確認:

  1. ブラウザの開発者ツールを開く
  2. Application → Local Storage → http://localhost:4200
  3. angular_todosキーにJSON形式でデータが保存されている

スクリーンショット 2025-12-18 2.19.06.png

Phase 5のまとめ

このPhaseで実装したこと:

  • TodoStorageServiceの作成
  • inject()による依存注入
  • Local Storageへの保存/読み込み

まとめ

おつかれさまでした!
5つのPhaseを通じて、Angular v21の実践的なTODOアプリを完成させました

この記事が、Angularの理解の一助となれば幸いです

明日は、@noxi515さんです!

6
1
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
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?