Dev Study
← 解説「シグナルフォーム — form() でフォームもシグナルに」に戻る

サンプルコードで身につける: シグナルフォーム — form() でフォームもシグナルに

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1最小のシグナルフォーム — モデルと form()

シグナルフォームの出発点は「フォームの中身をシグナルのモデルとして持つ」ことです。signal({ email: '' }) でモデルを作り form() に渡すと、各項目に対応したフィールドのツリーができます。値の実体はあくまでただのシグナルなので、model() でいつでも現在の中身を読み取れます。

import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

@Component({
  selector: 'app-minimal-signal-form',
  template: '<p>現在の入力: {{ model().email }}</p>',
})
export class MinimalSignalFormComponent {
  // フォームの中身はただのシグナルのモデル
  model = signal({ email: '' });

  // form() に渡すと、各項目に対応したフィールドのツリーができる
  loginForm = form(this.model);

  // 値の実体はモデルのシグナルなので、model() で読み取れる
}

2[formField] で入力欄とフィールドをつなぐ

テンプレートでは、入力欄に [formField]="loginForm.email" を付けてフィールドと双方向に同期します。ユーザーが入力すればモデルのシグナルが更新され、モデルを書き換えれば入力欄にも反映されます。リアクティブフォームの formControlName にあたる役割を、シグナル時代はこのディレクティブが担います。

import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

@Component({
  selector: 'app-profile-signal-form',
  imports: [FormField],
  // [formField] で入力欄とフィールドを双方向に同期する
  template:
    '<input [formField]="profileForm.name" placeholder="名前">' +
    '<input type="email" [formField]="profileForm.email" placeholder="メール">' +
    '<p>入力中: {{ model().name }} / {{ model().email }}</p>',
})
export class ProfileSignalFormComponent {
  model = signal({ name: '', email: '' });
  profileForm = form(this.model);
}

3スキーマ関数で required と email の検証を宣言する

検証は form() の第2引数に渡すスキーマ関数の中で宣言します。required(path.email, { message: '...' }) のように「どの項目にどの検証を効かせるか」を書くと、値が変わるたびに自動で再判定されます。検証ルールがフォーム定義の1か所にまとまるのが、この方式の読みやすさです。

import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';

@Component({
  selector: 'app-signup-signal-form',
  imports: [FormField],
  template:
    '<input type="email" [formField]="signupForm.email">' +
    '<input type="password" [formField]="signupForm.password">' +
    '<button [disabled]="signupForm().invalid()">登録</button>',
})
export class SignupSignalFormComponent {
  model = signal({ email: '', password: '' });

  // スキーマ関数の中で、どの項目にどの検証を効かせるかを宣言する
  signupForm = form(this.model, (path) => {
    required(path.email, { message: 'メールは必須です' });
    email(path.email, { message: 'メール形式で入力してください' });
    required(path.password, { message: 'パスワードは必須です' });
  });
}

4touched() と errors() でエラーを表示する

各フィールドの状態はすべてシグナルとして読み取れます。loginForm.email().touched() と invalid() を組み合わせて「触れたあとに無効ならエラー表示」とし、errors() を @for で回してメッセージを出します。リアクティブフォームで学んだ出し分けの考え方が、そのままシグナルの読み取りに置き換わった形です。

import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';

@Component({
  selector: 'app-login-signal-form',
  imports: [FormField],
  // touched() && invalid() のときだけ errors() のメッセージを表示する
  template:
    '<input type="email" [formField]="loginForm.email">' +
    '@if (loginForm.email().touched() && loginForm.email().invalid()) {' +
    '  @for (e of loginForm.email().errors(); track e) { <p>{{ e.message }}</p> }' +
    '}',
})
export class LoginSignalFormComponent {
  model = signal({ email: '' });

  loginForm = form(this.model, (path) => {
    required(path.email, { message: 'メールは必須です' });
    email(path.email, { message: 'メール形式で入力してください' });
  });
}

5computed と組み合わせる — 全部シグナルの利点

シグナルフォームでは値も妥当性もすべてシグナルなので、computed とそのまま組み合わせられます。この例ではモデルの値から確認用の表示文を、フォーム全体の invalid() から送信可否を、どちらも computed で導出しています。リアクティブフォームでは購読が必要だった連動が、宣言的に書けるのが利点です。

import { Component, computed, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';

@Component({
  selector: 'app-preview-signal-form',
  imports: [FormField],
  template:
    '<input [formField]="nameForm.name">' +
    '<p>{{ preview() }}</p>' +
    '<button [disabled]="!canSubmit()">送信</button>',
})
export class PreviewSignalFormComponent {
  model = signal({ name: '' });

  nameForm = form(this.model, (path) => {
    required(path.name, { message: '名前は必須です' });
  });

  // モデルの値から表示文を導出する。値が変われば自動で更新される
  preview = computed(() => `こんにちは、${this.model().name || '名無し'}さん`);

  // フォーム全体の妥当性もシグナルなので computed に組み込める
  canSubmit = computed(() => !this.nameForm().invalid());
}