Angular中級 #2 Template-driven Forms との比較

読了 9分

#1 では Angular のフォーム処理方式の 1 つである Reactive Forms を扱いました。FormGroupFormControl をコンポーネントクラスで直接作っておき、テンプレートはそのオブジェクトに接続するだけ、という方式でしたね。ところが Angular にはこれ以外にもう 1 つフォーム方式があります。それが Template-driven Forms です。

名前のとおり、この方式はフォームのすべてのロジックが テンプレート (HTML) の上で作られます。コンポーネントクラスにフォームオブジェクトを別途宣言する必要はなく、その代わりに ngModel というディレクティブがほぼすべての仕事をしてくれます。今回の記事では Template-driven Forms の使い方を見ていき、最後に 2 つの方式を並べて比較してみましょう。

Template-driven とは #

Template-driven Forms は、文字どおり テンプレートがフォームを主導 (drive) する 方式です。フォームの構造もバリデーションルールも状態追跡も、すべて HTML 内に書かれたディレクティブから Angular が推論します。

核となるのは ngModel というディレクティブです。実は 基礎 #3 で双方向バインディングを扱ったとき、すでに少しだけ会っていました。そのときは単に [(ngModel)]="name" のように変数と入力欄を結びつける道具として使いましたが、本当は ngModel はそれよりずっと多くの仕事をしていました。フォームの中で使われると自動でその入力欄をフォームの コントロール (control) として登録し、そのコントロールの値とバリデーション状態 (valid/invalid、touched/dirty など) を絶えず追跡します。

FormsModule のセットアップ #

Template-driven Forms を使うには FormsModule を import しなければなりません。standalone コンポーネントでは、コンポーネントの imports 配列に直接追加します。

src/app/signup-form.component.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  // これでテンプレートで ngModel、ngForm のようなディレクティブが使えます
}

Reactive Forms では ReactiveFormsModule を import していましたね。Template-driven ではその代わりに FormsModule が入る、と覚えておけば構いません。2 つのモジュールを同時に import しても衝突はしませんが、1 つのコンポーネントの中では普通どちらか 1 つの方式だけを使うのがすっきりします。

[(ngModel)] で双方向バインディング #

最も単純な例から見てみましょう。

src/app/signup-form.component.html
<input
  type="text"
  name="username"
  [(ngModel)]="username"
  placeholder="ID"
/>
<p>入力値: {{ username }}</p>
src/app/signup-form.component.ts
export class SignupFormComponent {
  username = '';
}

ここに 1 つ重要なルールがあります。ngModel をフォームの中で使うときは必ず name 属性を付けなければならない という点です。Angular はこの name の値をキーとして、フォームオブジェクトの中にコントロールを登録します。name がないと、コンソールにエラーが出てフォームが正しく動かなくなります。

注記
フォームの外で単純に入力値と変数を結びつける用途として [(ngModel)] を使うときは、name 属性がなくても構いません。しかし <form> タグの内側に入った瞬間、name が必須になります。「フォームの中では name もワンセット」と覚えておくと楽です。

ngForm(ngSubmit) #

それではフォーム全体を扱ってみましょう。<form> タグを使うと、Angular が自動でその場に NgForm インスタンスを作って付けてくれます。このオブジェクトにアクセスするには、テンプレート変数構文 (#) で参照を取ればよいです。

src/app/signup-form.component.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
  <label>
    ID
    <input type="text" name="username" [(ngModel)]="model.username" />
  </label>

  <label>
    メール
    <input type="email" name="email" [(ngModel)]="model.email" />
  </label>

  <button type="submit">登録</button>
</form>

<p>フォームは valid か? {{ userForm.valid }}</p>
src/app/signup-form.component.ts
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  model = { username: '', email: '' };

  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log('送信された値:', form.value);
    }
  }
}

#userForm="ngForm" は「このフォームに付いている ngForm ディレクティブインスタンスを userForm という名前で取れ」という意味です。こうやって取っておけば、テンプレート内でも、そして (ngSubmit) を通じてコンポーネントクラス内でも、フォーム全体オブジェクトを扱えます。

(submit) ではなく (ngSubmit) を使う理由は、(ngSubmit) がブラウザの既定のページリロード動作を自動で防ぎ、フォームがバリデーションされた状態で送信イベントを発行してくれるからです。Template-driven フォームではほぼ常に (ngSubmit) を使うものと考えて構いません。

バリデーション — HTML5 属性で #

Template-driven Forms の最大の魅力は バリデーションを HTML5 属性で書く という点です。requiredminlengthmaxlengthpatternemail のような標準の属性をそのまま書けば、Angular が自動でそれらをバリデーションルールとして吸収します。

src/app/signup-form.component.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
  <input
    type="text"
    name="username"
    [(ngModel)]="model.username"
    required
    minlength="3"
  />

  <input
    type="email"
    name="email"
    [(ngModel)]="model.email"
    required
    email
  />

  <input
    type="password"
    name="password"
    [(ngModel)]="model.password"
    required
    pattern="^(?=.*[A-Za-z])(?=.*\d).{8,}$"
  />

  <button type="submit" [disabled]="userForm.invalid">登録</button>
</form>

同じバリデーションを Reactive Forms で書くなら、Validators.requiredValidators.minLength(3)Validators.emailValidators.pattern(...) をコンポーネントクラスで配列として書かなければならなかったはずです。Template-driven ではそのすべてが HTML 1 行に収まってしまいます。短いフォームでは本当に楽ですね。

[disabled]="userForm.invalid" も注目に値します。フォーム全体が invalid な間は送信ボタンを自動で無効化するパターンです。boolean 変数もメソッドも別途用意する必要はありません。

フォーム状態とエラー表示 #

フォームの各コントロールは、次のような状態フラグを持ちます。

  • valid / invalid — バリデーションルールを通過したか
  • pristine / dirty — ユーザーが値を一度でも変えたか
  • untouched / touched — ユーザーが一度でもフォーカスを当てて外したか

これらのフラグを活用して「ユーザーが入力欄から離れた後にエラーを表示する」自然な UX を作れます。

src/app/signup-form.component.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
  <label>
    メール
    <input
      type="email"
      name="email"
      [(ngModel)]="model.email"
      #emailCtrl="ngModel"
      required
      email
    />
  </label>

  @if (emailCtrl.invalid && emailCtrl.touched) {
    <p class="error">
      @if (emailCtrl.errors?.['required']) {
        メールを入力してください。
      }
      @if (emailCtrl.errors?.['email']) {
        正しいメール形式ではありません。
      }
    </p>
  }

  <button type="submit" [disabled]="userForm.invalid">登録</button>
</form>

#emailCtrl="ngModel" は、フォーム全体ではなく 個別のコントロール 単位でディレクティブインスタンスを取っておくパターンです。こうやって取れば、そのコントロール 1 つの validtouchederrors といった情報に直接アクセスできます。

フォーム全体の単位でアクセスしたければ、userForm.controls['email']?.errors のように入っていくこともできます。コントロールが多くなれば、後者のほうがすっきりすることもあります。

Reactive vs Template-driven の比較 #

それでは 2 つの方式を並べて比較してみましょう。

項目Template-drivenReactive
フォーム定義の場所HTML テンプレートコンポーネントクラス
中核の道具ngModelngForm、HTML5 属性FormGroupFormControlValidators
import するモジュールFormsModuleReactiveFormsModule
学習難度低い — HTML フレンドリー中程度 — オブジェクトと Observable フレンドリー
動的フォーム (フィールド追加・削除)不便非常に自然 (FormArray)
ユニットテストテンプレートが必要で重いクラスだけテスト可能、軽い
非同期バリデーション可能だがぎこちない自然に統合される
値変化の購読ぎこちないvalueChanges Observable ですっきり

ざっくり整理すると次のとおりです。

  • Template-driven: フォームが単純で、バリデーションも HTML5 のレベルで十分で、「フォーム全体が HTML 1 画面の中で見えると嬉しい」と感じるとき。
  • Reactive: フォームが複雑だったり、入力に応じてフィールドが動的に増減したり、値の変化に応じて他の領域が反応する必要があったり、ユニットテストが重要なとき。

実務の推奨 — どちらを選ぶべきか #

新しいプロジェクトを始める方には、一般的に Reactive Forms を基本に据えることをお勧めします。最初はコード量が少し多く見えますが、フォームが育てば育つほどその構造の価値が現れます。バリデーションルールがクラスの中の一カ所に集まっているので追跡しやすく、新しいフィールドを 1 つ追加するときに何行を変えればよいかが明確で、ユニットテストもコンポーネントクラスだけですっきり書けます。

ヒント
意思決定基準の一行要約: フォームが育つ可能性が少しでもあれば Reactive で始めてください。「これは一生入力欄 2 つきりだ」と自信を持って言えるフォームだけ Template-driven で軽く処理しましょう。途中で 2 つの方式を混ぜて使うことはできますが、同じフォームの中で混ぜることは避けるほうがよいです。

そうかといって、Template-driven が時代遅れだとか役に立たないという意味では決してありません。ヘッダーの検索窓、ニュースレター購読フォーム、コメント入力のように 入力欄が 1〜2 個でバリデーションも軽いフォーム であれば、Reactive Forms のボイラープレートはむしろ過剰です。そういった場合は、Template-driven で HTML 数行で終わらせるほうがずっと自然です。

また、デザイナーやマークアップ担当者と協業する環境で、マークアップ中心に作業される方々が直接バリデーション属性まで触れるという点は無視できない長所です。チーム構成やプロジェクトの性格によって答えが変わりうる、という点は覚えておくとよいでしょう。

まとめ #

今回の記事では、Template-driven Forms の使い方と、Reactive Forms との比較を見てきました。整理すると:

  • Template-driven は テンプレート中心 にフォームを作る。ngModel と HTML5 属性が中核。
  • フォーム内の ngModel には name 属性が必須
  • #userForm="ngForm" でフォーム全体を、#ctrl="ngModel" で個別のコントロールを取る。
  • 単純で軽いフォームは Template-driven、複雑・動的・テストが重要なフォームは Reactive。
  • 新しいプロジェクトの基本は Reactive 優先、単純なフォームだけ Template-driven で。

次の記事である「Angular中級 #3 RxJS 基礎」では、Angular のもう 1 つの大きな軸である RxJS を扱います。Observable とは何で、なぜ Angular は HTTP レスポンスやルーターパラメータのような非同期の流れにそれを持ち込んだのか、そしてもっともよく使う演算子をいくつか手になじませてみましょう。

X