Angular のテンプレート駆動フォームとは、FormsModule の ngModel をテンプレート(HTML)に書くだけで、入力値の同期・検証・状態管理までを Angular に任せられるフォームの作り方です。 コンポーネントの imports に FormsModule を入れ、入力欄に name 属性と [(ngModel)] を付ければ準備は完了です。検証は required や minlength などの属性で宣言し、#name="ngModel" で取り出した状態を @if で見てメッセージを出し分けます。

この記事では、そのままコピーして動かせる会員登録フォームの完成コードを中心に、検証属性と errors キーの対応表、ng-valid / ng-dirty / ng-touched などの状態クラスが実際にどう切り替わるか、NG8002: Can't bind to 'ngModel' などのエラーの再現と直し方、リアクティブフォーム・Signal Forms との比較表までを1ページにまとめました。掲載している出力は、すべて下記の環境で実行して得たものです。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular 22.2.1(@angular/cli・@angular/forms とも)/ TypeScript 6.0.3 / Vitest 5.0.3 + jsdom 30.1.1。ng new で作ったプロジェクトで ng build と ng test を実行しています。

テンプレート駆動フォームの早見表

やりたいこと 書き方 補足
フォーム機能を使えるようにする imports: [FormsModule] @angular/forms から import。コンポーネントごとに書く
入力欄と値を双方向でつなぐ [(ngModel)]="model.name" と name="name" form タグの中では name 属性が必要
入力欄の状態を取り出す #name="ngModel" name.invalid、name.touched、name.errors が使える
フォーム全体の状態を取り出す #f="ngForm" f.valid、f.value、f.submitted が使える
送信を受け取る (ngSubmit)="onSubmit(f)" ページの再読み込みは起きない
必須にする required errors は {"required":true}
文字数を決める minlength="2" / maxlength="20" キーは minlength / maxlength(すべて小文字)
形式を決める pattern="[A-Z]{2}[0-9]{4}" / email pattern は全体一致で判定される
数値の範囲を決める min="18" / max="120" type="number" の入力欄で使う
エラーを出し分ける @if (name.hasError('required')) { ... } 表示の条件は name.touched || f.submitted と組み合わせる
触った欄・変更した欄に色を付ける CSS で .ng-invalid.ng-touched など クラスは Angular が自動で付け外しする
入力欄をまとめて入れ子の値にする ngModelGroup="address" f.value.address.zip の形になる
反映のタイミングを変える [ngModelOptions]="{ updateOn: 'blur' }" 既定は入力のたび(change)
フォームに含めない入力欄にする [ngModelOptions]="{ standalone: true }" name 属性は不要になる
初期状態に戻す f.resetForm() 値・状態クラス・submitted をまとめて戻す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularテンプレート駆動フォーム入門|ngModelとエラー表示」

テンプレート駆動フォームの考え方

Angular には、フォームを作る方法が3つあります。テンプレート駆動フォームは、検証ルールも値のひも付けもテンプレート側に書く方式です。コンポーネントのクラスには「入力値を入れておくプロパティ」と「送信時の処理」だけを書けば済むため、ログイン・問い合わせ・検索条件のような、項目が固定のフォームをすばやく作れます。

仕組みはシンプルです。FormsModule を読み込むと、次の3つのディレクティブが働きます。

ディレクティブ 付く場所 役割
NgForm form タグ(自動で付く) フォーム全体をまとめる FormGroup を裏側で作る
NgModel ngModel を書いた入力欄 入力欄1つ分の FormControl を裏側で作り、値・検証結果・状態を持つ
NgModelGroup ngModelGroup を書いた要素 複数の入力欄をまとめて入れ子の値にする

裏側では FormControl と FormGroup が作られているので、valid、errors、touched といったプロパティはリアクティブフォームと共通です。違うのは「誰がそれを作るか」で、テンプレート駆動ではディレクティブが作ってくれます。

いちばん小さな形:FormsModule を imports して ngModel を書く

まずは入力欄1つだけの例です。standalone コンポーネント(Angular 22 の ng new で作られる既定の形)では、ngModel を使うコンポーネント自身の imports に FormsModule を入れます。

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-box',
  imports: [FormsModule],
  template: `
    <input id="keyword" [(ngModel)]="keyword" placeholder="キーワード" />
    <p id="echo">入力中: {{ keyword }}</p>
  `,
})
export class SearchBox {
  keyword = '';
}

入力欄に「備品」と入れたときの表示は次のとおりです。

入力中: 備品

[(ngModel)] は、「プロパティの値を入力欄に表示する」と「入力されたらプロパティを書き換える」を1つにまとめた書き方です。form タグの外で単独で使うときは name 属性がなくても動きます。

完成フォーム:ngModel と @if でエラーを出し分ける

次が本題の完成コードです。名前・メールアドレス・会員コード・年齢・プラン・同意チェックの6項目を持つ会員登録フォームで、必須・文字数・形式・数値の範囲・選択・チェックボックスの検証をひととおり含んでいます。3つのファイルをそのまま置けば動きます。

コンポーネント(member-form.ts)

import { Component, signal } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';

interface Member {
  name: string;
  email: string;
  code: string;
  age: number | null;
  plan: string;
  agree: boolean;
}

@Component({
  selector: 'app-member-form',
  imports: [FormsModule, JsonPipe],
  templateUrl: './member-form.html',
  styleUrl: './member-form.css',
})
export class MemberForm {
  protected readonly plans = ['無料', '標準', '上位'];
  protected model: Member = { name: '', email: '', code: '', age: null, plan: '', agree: false };
  protected readonly saved = signal<Member | null>(null);

  protected onSubmit(f: NgForm): void {
    if (f.invalid) {
      f.control.markAllAsTouched();
      return;
    }
    this.saved.set({ ...this.model });
  }

  protected onReset(f: NgForm): void {
    f.resetForm({ plan: '', agree: false });
    this.saved.set(null);
  }
}

テンプレート(member-form.html)

<form #f="ngForm" (ngSubmit)="onSubmit(f)" novalidate>
  <div class="field">
    <label for="name">名前</label>
    <input id="name" name="name" [(ngModel)]="model.name" #name="ngModel"
           required minlength="2" maxlength="20" />
    @if (name.invalid && (name.touched || f.submitted)) {
      <p class="error" id="name-error">
        @if (name.hasError('required')) {
          名前を入力してください。
        } @else if (name.hasError('minlength')) {
          名前は{{ name.errors?.['minlength'].requiredLength }}文字以上で入力してください(いま{{ name.errors?.['minlength'].actualLength }}文字)。
        }
      </p>
    }
  </div>

  <div class="field">
    <label for="email">メールアドレス</label>
    <input id="email" name="email" type="email" [(ngModel)]="model.email" #email="ngModel"
           required email />
    @if (email.invalid && (email.touched || f.submitted)) {
      <p class="error" id="email-error">
        @if (email.hasError('required')) {
          メールアドレスを入力してください。
        } @else if (email.hasError('email')) {
          メールアドレスの形式で入力してください。
        }
      </p>
    }
  </div>

  <div class="field">
    <label for="code">会員コード(英大文字2字+数字4桁)</label>
    <input id="code" name="code" [(ngModel)]="model.code" #code="ngModel"
           required pattern="[A-Z]{2}[0-9]{4}" />
    @if (code.invalid && (code.touched || f.submitted)) {
      <p class="error" id="code-error">
        @if (code.hasError('required')) {
          会員コードを入力してください。
        } @else if (code.hasError('pattern')) {
          AB1234 の形式で入力してください。
        }
      </p>
    }
  </div>

  <div class="field">
    <label for="age">年齢</label>
    <input id="age" name="age" type="number" [(ngModel)]="model.age" #age="ngModel"
           required min="18" max="120" />
    @if (age.invalid && (age.touched || f.submitted)) {
      <p class="error" id="age-error">
        @if (age.hasError('required')) {
          年齢を入力してください。
        } @else if (age.hasError('min')) {
          {{ age.errors?.['min'].min }}以上で入力してください。
        } @else if (age.hasError('max')) {
          {{ age.errors?.['max'].max }}以下で入力してください。
        }
      </p>
    }
  </div>

  <div class="field">
    <label for="plan">プラン</label>
    <select id="plan" name="plan" [(ngModel)]="model.plan" #plan="ngModel" required>
      <option value="">選択してください</option>
      @for (p of plans; track p) {
        <option [value]="p">{{ p }}</option>
      }
    </select>
    @if (plan.invalid && (plan.touched || f.submitted)) {
      <p class="error" id="plan-error">プランを選択してください。</p>
    }
  </div>

  <div class="field">
    <label>
      <input type="checkbox" name="agree" [(ngModel)]="model.agree" #agree="ngModel" required />
      利用規約に同意する
    </label>
    @if (agree.invalid && (agree.touched || f.submitted)) {
      <p class="error" id="agree-error">同意にチェックを入れてください。</p>
    }
  </div>

  <button type="submit">登録する</button>
  <button type="button" (click)="onReset(f)">クリア</button>
  <p id="status">フォームの状態: {{ f.status }} / 送信済み: {{ f.submitted }}</p>
</form>

@if (saved(); as m) {
  <pre id="saved">{{ m | json }}</pre>
}

スタイル(member-form.css)

.field { margin-bottom: 12px; }
.error { color: #b00020; margin: 4px 0 0; font-size: 0.9em; }
input.ng-invalid.ng-touched, select.ng-invalid.ng-touched { border: 2px solid #b00020; }
input.ng-valid.ng-dirty, select.ng-valid.ng-dirty { border: 2px solid #2e7d32; }

使う側は、imports に MemberForm を入れてタグを置くだけです。

import { Component } from '@angular/core';
import { MemberForm } from './tf/member-form';

@Component({
  selector: 'app-root',
  imports: [MemberForm],
  template: `<h1>会員登録</h1><app-member-form />`,
})
export class App {}

このフォームを動かした結果

入力→フォーカスを外す→送信→クリア、の順に操作して、クラス・errors・表示されるメッセージを記録しました(Vitest + jsdom で実行)。

初期 name class: ng-untouched ng-pristine ng-invalid
初期 form class: ng-untouched ng-pristine ng-invalid
初期 errors name={"required":true} email={"required":true} agree={"required":true}
初期 エラー表示数: 0
blur後 name class: ng-pristine ng-invalid ng-touched
blur後 表示: 名前を入力してください。
1文字 name class: ng-invalid ng-touched ng-dirty
1文字 errors: {"minlength":{"requiredLength":2,"actualLength":1}}
1文字 表示: 名前は2文字以上で入力してください(いま1文字)。
2文字 name class: ng-touched ng-dirty ng-valid errors: null
email=yamada errors: {"email":true}
email=yamada@example errors: null
code=ab12 errors: {"pattern":{"requiredPattern":"^[A-Z]{2}[0-9]{4}$","actualValue":"ab12"}}
age=15 errors: {"min":{"min":18,"actual":15}}
age=130 errors: {"max":{"max":120,"actual":130}}
form.value: {"name":"山田","email":"yamada@example","code":"ab12","age":130,"plan":"","agree":false}
form.status: INVALID
送信後 form class: ng-invalid ng-touched ng-dirty ng-submitted
送信後 エラー表示: code-error,age-error,plan-error,agree-error
送信後 saved: なし
全入力後 form.status: VALID / form class: ng-touched ng-dirty ng-submitted ng-valid
saved: { "name": "山田", "email": "yamada@example.com", "code": "AB1234", "age": 30, "plan": "標準", "agree": true }
クリア後 form class: ng-untouched ng-pristine ng-invalid
クリア後 value: {"name":null,"email":null,"code":null,"age":null,"plan":"","agree":false}

この出力から読み取れるポイントは次の5つです。

  • 最初から errors は入っている({"required":true})が、表示条件に touched を入れているのでメッセージは0件。開いた瞬間に赤い文字が並ぶことはありません。
  • フォーカスを外すと ng-touched に変わり、そこで初めて「名前を入力してください。」が出ます。
  • minlength のエラーには requiredLength と actualLength が入るので、「あと何文字」をメッセージに組み込めます。
  • email は yamada@example を通します。ドメインにドットがない形も形式としては有効と判定されるため、厳しくしたいときは pattern を足します。
  • 送信ボタンを押すと form に ng-submitted が付き、触っていない欄(プラン・同意)のエラーも表示されます。onSubmit の中の markAllAsTouched() と、表示条件の f.submitted の両方が効いています。

エラー表示の書き方を分解する

完成フォームのエラー表示は、どの項目も同じ3段構えです。名前の欄を例に見てみます。

<input id="name" name="name" [(ngModel)]="model.name" #name="ngModel"
       required minlength="2" maxlength="20" />
@if (name.invalid && (name.touched || f.submitted)) {
  <p class="error" id="name-error">
    @if (name.hasError('required')) {
      名前を入力してください。
    } @else if (name.hasError('minlength')) {
      名前は{{ name.errors?.['minlength'].requiredLength }}文字以上で入力してください。
    }
  </p>
}
部分 意味
#name="ngModel" この入力欄の NgModel ディレクティブを、テンプレート変数 name に入れる。="ngModel" を省くと入力欄の DOM 要素が入り、invalid などは取れない
name.invalid 検証エラーが1つでもあれば true
name.touched || f.submitted 「一度フォーカスを外した」または「送信ボタンを押した」ときだけ表示する条件
name.hasError('required') errors に required キーがあるか。name.errors?.['required'] と同じ意味
@else if 上から順に1つだけ表示する。複数のエラーを同時に出さないための並べ方

表示条件の選び方は、次の表を目安にすると決めやすくなります。

表示のタイミング 条件式 向いている場面
フォーカスを外したあと c.invalid && c.touched 入力途中は静かに見守りたい一般的なフォーム
1文字でも入力したあと c.invalid && c.dirty 入力しながら形式を確認してほしい欄(パスワードの条件など)
送信ボタンを押したあと c.invalid && f.submitted 項目が少なく、送信時にまとめて知らせたいフォーム
上の組み合わせ c.invalid && (c.touched || f.submitted) この記事の完成フォーム。迷ったらこの形

検証属性と errors キーの対応表

テンプレート駆動フォームでは、HTML の属性を書くと、対応する検証ディレクティブが自動で働きます。 errors オブジェクトのキーと中身は次のとおりです(「実際の出力」の列は、この記事の環境で取得した値です)。

属性 判定 errors のキー 実際の出力
required 空文字・null のときエラー required {"required":true}
required(チェックボックス) チェックがないときエラー required {"required":true}
minlength="2" 文字数が足りないときエラー minlength {"minlength":{"requiredLength":2,"actualLength":1}}
maxlength="5" 文字数が多いときエラー maxlength {"maxlength":{"requiredLength":5,"actualLength":9}}
pattern="[A-Z]{2}[0-9]{4}" 正規表現に全体一致しないときエラー pattern {"pattern":{"requiredPattern":"^[A-Z]{2}[0-9]{4}$","actualValue":"ab12"}}
email メールアドレスの形式でないときエラー email {"email":true}
min="18" 数値が小さいときエラー min {"min":{"min":18,"actual":15}}
max="120" 数値が大きいときエラー max {"max":{"max":120,"actual":130}}

覚えておくと役立つ点を3つ挙げます。

  • キーはすべて小文字です。属性が minlength なので、hasError('minLength') と書くと一致しません。
  • pattern は前後に ^ と $ が補われます。出力の requiredPattern が ^[A-Z]{2}[0-9]{4}$ になっているとおり、部分一致ではなく全体一致で判定されます。
  • minlength・pattern・email・min・max は、空の入力をエラーにしません。「空でもよいが、入れるなら形式を守る」という任意項目にそのまま使えます。必須にしたいときは required を併記します。

maxlength はブラウザ側で入力そのものを止めるため、キーボードからは超過できません。上の表の maxlength の出力は、値をプログラムから入れて確認したものです。初期値の読み込みや貼り付け後の加工で長さを超えるケースに備えた検証と考えるとよいでしょう。

状態クラス一覧:ng-valid・ng-dirty・ng-touched

ngModel を付けた入力欄と form タグには、状態に応じた CSS クラスが自動で付きます。CSS だけで「触った欄のうち、エラーのあるものに赤枠」のような見た目を作れるのが利点です。

クラス 付く条件 対になるクラス 対応するプロパティ
ng-valid 検証エラーがない ng-invalid valid / invalid
ng-invalid 検証エラーが1つ以上ある ng-valid invalid
ng-pending 非同期の検証が進行中 (なし) pending
ng-pristine 利用者がまだ値を変えていない ng-dirty pristine
ng-dirty 利用者が値を変えた ng-pristine dirty
ng-untouched まだフォーカスを外していない ng-touched untouched
ng-touched 一度フォーカスを外した ng-untouched touched
ng-submitted 送信した(form タグだけに付く) (なし) f.submitted

名前の欄で、操作ごとにクラスがどう変わったかを並べると次のようになります。

操作 name の入力欄のクラス
表示した直後 ng-untouched ng-pristine ng-invalid
フォーカスを外す ng-pristine ng-invalid ng-touched
1文字入力(文字数が足りない) ng-invalid ng-touched ng-dirty
2文字入力(条件を満たす) ng-touched ng-dirty ng-valid
クリア(resetForm)後 ng-untouched ng-pristine ng-invalid

dirty は「値を変えたか」、touched は「フォーカスを外したか」で、別々に動きます。タブキーで通り過ぎただけなら touched だけが付き、入力中でフォーカスがまだ中にあれば dirty だけが付きます。

完成フォームの CSS は、この組み合わせを使っています。

input.ng-invalid.ng-touched { border: 2px solid #b00020; } /* 触ったあとでエラー */
input.ng-valid.ng-dirty { border: 2px solid #2e7d32; }     /* 入力して条件を満たした */

NgForm と NgModel で使えるプロパティ

#f="ngForm" と #name="ngModel" で取り出した変数から、次のプロパティ・メソッドを使えます。

対象 プロパティ・メソッド 内容
共通 valid / invalid / pending 検証結果
共通 dirty / pristine / touched / untouched 操作の状態
共通 errors エラーのオブジェクト。なければ null
共通 status VALID / INVALID / PENDING / DISABLED の文字列
共通 value 現在の値
共通 hasError('キー') / getError('キー') 特定のエラーの有無・中身
ngForm submitted 送信済みかどうか
ngForm controls name 属性をキーにした各入力欄
ngForm control 裏側の FormGroup。markAllAsTouched() や getRawValue() を呼べる
ngForm resetForm(値) 値と状態を初期化し、submitted も false に戻す

送信ボタンを [disabled]="f.invalid" で押せなくする書き方もよく使われます。ただ、押せない理由が画面に出ないと利用者が迷いやすいため、この記事の完成フォームではボタンは押せるままにして、押したら全項目のエラーを表示する形にしました。どちらを選ぶかは、項目数と案内のしやすさで決めるとよいでしょう。

ngModel の3つの書き方と、まとめ方・タイミングの指定

ngModel には3つの書き方があります。

書き方 データの流れ 使いどころ
ngModel(かっこなし) 入力欄 → フォームの値だけ クラスにプロパティを作らず、送信時に f.value でまとめて受け取る
[ngModel]="q" プロパティ → 入力欄(一方向) 入力時の処理を (ngModelChange) で自分で書きたいとき
[(ngModel)]="q" 双方向 もっとも一般的な形

次のコンポーネントは、ngModelGroup・ngModelOptions・disabled・一方向バインディングをまとめて試せる例です。

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-options-demo',
  imports: [FormsModule],
  template: `
    <form #f="ngForm">
      <input id="title" name="title" [(ngModel)]="title" maxlength="5" />
      <div ngModelGroup="address">
        <input id="zip" name="zip" [(ngModel)]="zip" />
        <input id="city" name="city" [(ngModel)]="city" />
      </div>
      <input id="memo" [(ngModel)]="memo" [ngModelOptions]="{ standalone: true }" />
      <input id="nick" name="nick" [(ngModel)]="nick" [ngModelOptions]="{ updateOn: 'blur' }" />
      <input id="off" name="off" [(ngModel)]="off" disabled />
      <input id="q" name="q" [ngModel]="q" (ngModelChange)="onQuery($event)" />
    </form>
  `,
})
export class OptionsDemo {
  title = '';
  zip = '100-0001';
  city = '東京';
  memo = 'メモ';
  nick = '';
  off = 'x';
  q = '';

  onQuery(value: string): void {
    this.q = value.trim();
  }
}

表示した直後の値と、操作後の値は次のとおりでした。

f.value: {"title":"","address":{"zip":"100-0001","city":"東京"},"nick":"","q":""}
f.control.getRawValue(): {"title":"","address":{"zip":"100-0001","city":"東京"},"nick":"","off":"x","q":""}
nick 入力直後: model="" class=ng-untouched ng-pristine ng-valid
nick blur後: model="taro" class=ng-valid ng-touched ng-dirty
q: model="備品" f.value.q="備品"
指定 結果
ngModelGroup="address" zip と city が address の下に入れ子になる
[ngModelOptions]="{ standalone: true }" memo は f.value に含まれない。フォームの検証結果にも影響しない
[ngModelOptions]="{ updateOn: 'blur' }" 入力中はモデルが空のまま。フォーカスを外したときに値・検証・ng-dirty がまとめて反映される
disabled off は f.value に含まれない。含めて取りたいときは f.control.getRawValue()
[ngModel] + (ngModelChange) 前後の空白を取り除いてからプロパティに入れられる(" 備品 " → "備品")

updateOn には 'change'(既定)・'blur'・'submit' を指定できます。入力のたびに重い処理が走る欄では 'blur' にすると、処理の回数を抑えられます。

クリアするときは resetForm を使う

f.resetForm() は、値・状態クラス・submitted をまとめて初期状態に戻します。 実行後のクラスは ng-untouched ng-pristine ng-invalid に戻り、エラーメッセージも消えました。

1つ知っておきたいのは、引数で指定しなかった項目の値は null になることです。先ほどの出力でも、クリア後は次のようになっていました。

クリア後 value: {"name":null,"email":null,"code":null,"age":null,"plan":"","agree":false}

[(ngModel)] でつないだ model.name も null になるため、あとで model.name.length のように文字列として扱う処理があるなら、f.resetForm({ name: '', email: '', code: '', age: null, plan: '', agree: false }) のように全項目の初期値を渡しておくと安心です。

よくあるエラーと直し方

テンプレート駆動フォームで出会いやすいエラーを3つ再現しました。どれも原因がはっきりしているので、メッセージと直し方をセットで覚えておくとすぐに解決できます。

エラー 出るタイミング 原因 直し方
NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'. ビルド時 FormsModule を imports していない そのコンポーネントの imports に FormsModule を足す
NG8003: No directive found with exportAs 'ngModel'.('ngForm' も同様) ビルド時 同上(#x="ngModel" や #f="ngForm" を解決できない) 同上
NG01352: If ngModel is used within a form tag, either the name attribute must be set ... 実行時 form タグの中の ngModel に name 属性がない name 属性を付ける。フォームに含めないなら standalone: true

NG8002:Can't bind to 'ngModel'

imports に FormsModule がないコンポーネントで [(ngModel)] を書くと、ng build が次のメッセージで止まります。

// エラーになる例:imports がない
@Component({
  selector: 'app-bad',
  template: `<input [(ngModel)]="keyword" />`,
})
export class Bad {
  keyword = '';
}
✘ [ERROR] NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'. Find more at https://next.angular.dev/errors/NG8002 [plugin angular-compiler]

    src/app/tf/bad.ts:5:20:
      5 │   template: `<input [(ngModel)]="keyword" />`,
        ╵                     ~~~~~~~~~~~~~~~~~~~~~

直し方は、エラーが出たファイルのコンポーネントに imports: [FormsModule] を足すことです。standalone コンポーネントでは、親や app.config.ts で読み込んでいても子には引き継がれません。使うコンポーネントごとに書きます。直した形は、この記事の最初に載せた SearchBox と同じです。

NG8003:No directive found with exportAs

[(ngModel)] を使わず、ngModel(かっこなし)と #k="ngModel" だけを書いていた場合は、NG8002 ではなく NG8003 が出ます。原因と直し方は同じです。

✘ [ERROR] NG8003: No directive found with exportAs 'ngModel'. Find more at https://next.angular.dev/errors/NG8003 [plugin angular-compiler]

    src/app/tf/bad.ts:5:77:
      5 │ ... (ngSubmit)="go()"><input name="k" ngModel #k="ngModel" /></form>`,
        ╵                                                   ~~~~~~~

✘ [ERROR] NG8003: No directive found with exportAs 'ngForm'. Find more at https://next.angular.dev/errors/NG8003 [plugin angular-compiler]

NG01352:form の中で name 属性を付け忘れた

FormsModule は読み込んであるのに、form タグの中の入力欄に name がない場合は、ビルドは通り、表示するときに次のエラーになります。

<!-- エラーになる例:name がない -->
<form><input id="a" [(ngModel)]="a" /></form>
NG01352: If ngModel is used within a form tag, either the name attribute must be set or the form
    control must be defined as 'standalone' in ngModelOptions.

    Example 1: <input [(ngModel)]="person.firstName" name="first">
    Example 2: <input [(ngModel)]="person.firstName" [ngModelOptions]="{standalone: true}">

NgForm は name 属性をキーにして各入力欄を登録するため、form の中では名前が必要になります。メッセージにあるとおり、直し方は2通りです。

直し方 書き方 選ぶ基準
名前を付ける name="a" を足す その欄を f.value や検証結果に含めたい(ほとんどの場合はこちら)
フォームから切り離す [ngModelOptions]="{ standalone: true }" 表示の切り替え用チェックボックスなど、送信内容に含めない欄

独自の検証ルールを足す:検証ディレクティブ

属性で用意されていないルールは、NG_VALIDATORS に登録したディレクティブとして作ると、required と同じように属性で使えます。次は「指定した語を含んでいたらエラー」にする例です。

import { Directive, input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';

@Directive({
  selector: '[appNgWord]',
  providers: [{ provide: NG_VALIDATORS, useExisting: NgWordDirective, multi: true }],
})
export class NgWordDirective implements Validator {
  readonly appNgWord = input.required<string>();

  validate(control: AbstractControl): ValidationErrors | null {
    const value = String(control.value ?? '');
    return value.includes(this.appNgWord())
      ? { ngWord: { word: this.appNgWord(), actual: value } }
      : null;
  }
}

使う側は imports にディレクティブを足して、属性を書きます。

@Component({
  imports: [FormsModule, NgWordDirective],
  template: `<form #f="ngForm"><input id="t" name="t" [(ngModel)]="t" appNgWord="テスト" /></form>`,
})
class WithDirective { t = ''; }

「これはテストです」「こんにちは」の順に入力したときの errors は次のとおりです。

ngWord errors: {"ngWord":{"word":"テスト","actual":"これはテストです"}}
ngWord errors: null

テンプレートでは t.hasError('ngWord') で出し分けられます。エラーなら「キーと詳細を持つオブジェクト」、問題なければ null を返す、という約束だけ守れば、どんなルールでも同じ形で追加できます。

Vitest でフォームをテストする

Angular 22 の ng new では Vitest が最初から入っているので、ng test だけでフォームの動きを確かめられます。入力は「value を入れて input イベントを発火する」、フォーカスを外す操作は「blur イベントを発火する」で再現できます。

import { TestBed } from '@angular/core/testing';
import { MemberForm } from './member-form';

describe('MemberForm', () => {
  async function setup() {
    const fixture = TestBed.createComponent(MemberForm);
    await fixture.whenStable();
    const root: HTMLElement = fixture.nativeElement;
    const input = (selector: string) => root.querySelector(selector) as HTMLInputElement;
    const type = async (selector: string, value: string) => {
      input(selector).value = value;
      input(selector).dispatchEvent(new Event('input'));
      input(selector).dispatchEvent(new Event('blur'));
      await fixture.whenStable();
    };
    return { fixture, root, input, type };
  }

  it('最初はエラーを表示しない', async () => {
    const { root } = await setup();
    expect(root.querySelectorAll('.error')).toHaveLength(0);
  });

  it('1文字だと minlength のメッセージが出る', async () => {
    const { root, type } = await setup();
    await type('#name', 'あ');
    expect(root.querySelector('#name-error')?.textContent).toContain('2文字以上');
  });

  it('空のまま送信すると全項目のエラーが出て、保存されない', async () => {
    const { fixture, root } = await setup();
    (root.querySelector('button[type=submit]') as HTMLButtonElement).click();
    await fixture.whenStable();
    expect(root.querySelectorAll('.error')).toHaveLength(6);
    expect(root.querySelector('form')?.classList.contains('ng-submitted')).toBe(true);
    expect(root.querySelector('#saved')).toBeNull();
  });
});
 ✓  demo  src/app/tf/member-form.unit.spec.ts > MemberForm > 最初はエラーを表示しない 113ms
 ✓  demo  src/app/tf/member-form.unit.spec.ts > MemberForm > 1文字だと minlength のメッセージが出る 30ms
 ✓  demo  src/app/tf/member-form.unit.spec.ts > MemberForm > 空のまま送信すると全項目のエラーが出て、保存されない 28ms

 Test Files  1 passed (1)
      Tests  3 passed (3)

ポイントは await fixture.whenStable() を挟むことです。テンプレート駆動フォームは、入力欄の登録や値の反映を変更検知のサイクルに合わせて非同期で行うため、操作のあとに待ってから結果を確認します。

リアクティブフォーム・Signal Forms との違い

Angular 22.2.1 には、テンプレート駆動フォームのほかに、リアクティブフォーム(ReactiveFormsModule)と Signal Forms(@angular/forms/signals)が入っています。3つを並べると次のようになります。

観点 テンプレート駆動フォーム リアクティブフォーム Signal Forms
読み込むもの FormsModule ReactiveFormsModule @angular/forms/signals の FormField など
フォームの定義場所 テンプレート(ディレクティブが暗黙に作る) クラス(FormGroup / FormControl を明示的に作る) クラス(form(signal(...), スキーマ) で作る)
入力欄とのつなぎ方 [(ngModel)] と name formControlName / [formControl] [formField]
検証ルールの書き方 属性(required など)と検証ディレクティブ 関数(Validators.required など) スキーマ内の関数(required(path)、validate(path, fn) など)
値の持ち方 クラスのプロパティを書き換える フォームモデルが新しい値を返す シグナル
モデルへの反映 非同期(変更検知に合わせる) 同期 シグナルの更新
状態の読み方 #name="ngModel" で name.invalid form.controls.name.invalid フィールドの状態(FieldState)から読む

テンプレート駆動とリアクティブの違い(定義場所、データモデル、同期か非同期か、検証が関数かディレクティブか)は、Angular 公式ドキュメントのフォーム概要ページにある比較表に沿っています。公式の同ページでは、リアクティブフォームは画面を描画せずにテストしやすく、規模が大きく複雑なフォームに向くこと、テンプレート駆動は小さくシンプルなフォームに向くことも説明されています。Signal Forms の列は、22.2.1 の型定義で公開されている API(form、FormField、FieldState、required、validate、validateAsync など)をもとにしています。

どれを選ぶかの判断表

状況 選びやすい方式 理由
ログイン・問い合わせ・検索条件など、項目が固定で数個 テンプレート駆動 HTML を読めば仕様がわかり、書く量が少ない
HTML のフォームに慣れたメンバーが多い テンプレート駆動 required や pattern など標準の属性の知識がそのまま使える
項目の増減、条件によって変わる検証、複数項目の相関チェックが多い リアクティブ 検証を関数として組み立て・差し替えできる
検証ロジックをクラス単体でテストしたい リアクティブ 画面を描画せずにフォームモデルを確認できる
画面全体をシグナルでそろえたい新規の画面 Signal Forms 値も状態もシグナルで扱える

3つは同じアプリの中で併用できます。 画面ごとに合うものを選べばよく、テンプレート駆動で作ったフォームが育って項目や条件が増えてきたら、その画面だけリアクティブフォームに書き換える、という進め方も自然です。裏側の FormControl・errors・状態クラスの考え方は共通なので、ここで覚えた知識はそのまま活かせます。

よくある質問

Q. [(ngModel)] を書いたのに「Can't bind to 'ngModel'」と出ます。 A. そのコンポーネントの imports に FormsModule が入っているかを確認してください。standalone コンポーネントでは、使うコンポーネントごとに imports: [FormsModule] が必要です。import { FormsModule } from '@angular/forms'; の行も忘れずに足します。

Q. エラーメッセージが最初から表示されてしまいます。 A. 表示条件が name.invalid だけになっていないかを見てください。必須の欄は開いた直後から invalid です。name.invalid && (name.touched || f.submitted) のように、操作の状態と組み合わせると、触ったあと・送信したあとにだけ表示できます。

Q. #name と #name="ngModel" は何が違いますか。 A. #name だけだと入力欄の DOM 要素が入り、name.value は読めますが name.invalid や name.errors はありません。="ngModel" を付けると NgModel ディレクティブが入り、検証結果と状態を読めるようになります。

Q. 送信時に値をまとめて受け取るにはどうしますか。 A. (ngSubmit)="onSubmit(f)" で NgForm を渡し、f.value を読みます。name 属性をキーにしたオブジェクトになります。disabled の欄は含まれないので、含めたいときは f.control.getRawValue() を使います。[(ngModel)] でプロパティにつないでいる場合は、そのプロパティを読んでも同じ値が得られます。

まとめ

  • 準備は imports: [FormsModule] と、入力欄の name 属性 + [(ngModel)] の2つ。standalone ではコンポーネントごとに imports する。
  • エラー表示は #name="ngModel" と @if の組み合わせ。条件は name.invalid && (name.touched || f.submitted) が扱いやすい。
  • 検証属性と errors のキーは1対1(required・minlength・maxlength・pattern・email・min・max)。キーはすべて小文字。
  • 状態クラスは ng-valid / ng-invalid、ng-pristine / ng-dirty、ng-untouched / ng-touched、form の ng-submitted。CSS だけで見た目を切り替えられる。
  • NG8002・NG8003 は FormsModule の imports、NG01352 は name 属性を確認すれば解決できる。
  • クリアは f.resetForm()。指定しなかった項目は null になるので、初期値を渡しておくと扱いやすい。
  • 項目が固定の小さなフォームはテンプレート駆動、動的で複雑なフォームはリアクティブ、シグナルでそろえたい画面は Signal Forms と使い分ける。

参考

  • Angular 公式ドキュメント「テンプレート駆動型フォームの構築」 https://angular.jp/guide/forms/template-driven-forms
  • Angular 公式ドキュメント「フォーム入力の検証」 https://angular.jp/guide/forms/form-validation
  • Angular 公式ドキュメント「Forms in Angular」 https://angular.dev/guide/forms

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularテンプレート駆動フォーム入門|ngModelとエラー表示」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?