- Angular
Angularテンプレート駆動フォーム入門|ngModelとエラー表示
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期連続の黒字決算。