Angular のリアクティブフォームとは、フォームの構造・初期値・バリデーションを TypeScript 側に FormControl・FormGroup・FormArray として定義し、テンプレートの入力欄と結びつける方式です。 値・状態(有効か、触ったか)・エラーをすべてクラス側のオブジェクトから読めるので、入力チェックの多い画面や、行を増減するフォームを見通しよく書けます。

この記事では、FormControl → FormGroup → FormBuilder → FormArray の順に、型付きフォーム(NonNullableFormBuilder)と @for を使った完成コードを載せます。Validators の全種類については、エラー時に errors へ入るキーと中身を実際に出力して一覧表にしました。setValue と patchValue、value と getRawValue() の違い、「NG01050: formControlName must be used with a parent formGroup」などのエラーも、再現した結果つきで説明します。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular CLI 22.2.1・Angular 22.2.1(ng new の既定構成)/ TypeScript 6.0.3 / Vitest 5.0.3。画面の動作は Playwright 1.56.0 の Chromium でも確認しました。

Angular リアクティブフォームの早見表

やりたいこと 書き方(コピペ用)
フォームを使えるようにする コンポーネントの imports: [ReactiveFormsModule]
入力欄を1つ作る new FormControl('', { nonNullable: true })
複数の項目をまとめる new FormGroup({ name: new FormControl(...) })
短く書く(型付き) inject(NonNullableFormBuilder).group({ name: ['', Validators.required] })
行を増減する fb.array([...])、push()、removeAt(i)
テンプレートと結びつける [formGroup]="form"、formControlName="name"、formGroupName、formArrayName
エラーを表示する @if (c.invalid && (c.touched || c.dirty)) { ... }
エラーの種類を調べる c.hasError('required')、c.errors?.['minlength']
一部だけ値を入れる form.patchValue({ name: 'taro' })
全項目に値を入れる form.setValue({ ...全項目 })
無効化した項目も含めて値を取る form.getRawValue()
送信時に全項目のエラーを出す form.markAllAsTouched()
プロパティ 意味 true になるとき
valid / invalid バリデーションの結果 エラーがない / ある
pending 非同期バリデータの結果待ち 非同期の確認中
touched / untouched 入力欄から離れたか 一度フォーカスを外した(blur)
dirty / pristine 利用者が値を変えたか 画面から値を変更した
status 状態の文字列 VALID・INVALID・PENDING・DISABLED のいずれか

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

(記載例)出典:株式会社RJC「Angularリアクティブフォームの使い方|FormGroup・バリデーション」

FormControl から FormArray まで、順に組み立てる

4つのクラスの関係は次のとおりです。

クラス 役割 値の形
FormControl 入力欄1つ 文字列・数値・真偽値など
FormGroup 名前付きの項目の集まり オブジェクト { name: ..., email: ... }
FormArray 同じ形の項目の並び(数が変わる) 配列 [...]
FormBuilder / NonNullableFormBuilder 上の3つを短く作るためのサービス —

以下は、4つを順に動かして値と状態を出力するテストファイルです。src/app/steps.spec.ts として保存し、npx ng test --watch=false --reporters=verbose で実行できます。

import { TestBed } from '@angular/core/testing';
import {
  FormArray, FormBuilder, FormControl, FormGroup, NonNullableFormBuilder, Validators,
} from '@angular/forms';

const show = (label: string, v: unknown) => console.log(label, JSON.stringify(v));

describe('FormControl → FormGroup → FormBuilder → FormArray', () => {
  it('1. FormControl', () => {
    const name = new FormControl('', { nonNullable: true, validators: [Validators.required] });
    show('初期値:', { value: name.value, status: name.status, errors: name.errors });
    name.setValue('taro');
    show('setValue 後:', { value: name.value, status: name.status, errors: name.errors });
    name.reset();
    show('reset 後(nonNullable):', { value: name.value });

    const nullable = new FormControl('abc');
    nullable.reset();
    show('reset 後(指定なし):', { value: nullable.value });
  });

  it('2. FormGroup', () => {
    const form = new FormGroup({
      name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
      email: new FormControl('', { nonNullable: true, validators: [Validators.email] }),
    });
    show('value:', form.value);
    show('valid:', form.valid);
    form.controls.name.setValue('taro');
    show('name 入力後 valid:', form.valid);
    show('get で取り出す:', form.get('email')?.value);
  });

  it('3. FormBuilder', () => {
    const fb = TestBed.inject(NonNullableFormBuilder);
    const form = fb.group({
      name: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      address: fb.group({ zip: [''], city: [''] }),
    });
    show('value:', form.value);
    form.controls.email.setValue('abc');
    show('email の errors:', form.controls.email.errors);

    const loose = TestBed.inject(FormBuilder).group({ name: ['taro'] });
    loose.reset();
    show('FormBuilder の reset 後:', loose.value);
    form.controls.name.setValue('taro');
    form.reset();
    show('NonNullableFormBuilder の reset 後:', form.value);
  });

  it('4. FormArray', () => {
    const fb = TestBed.inject(NonNullableFormBuilder);
    const items = fb.array([fb.group({ name: ['ノート'], quantity: [1] })]);
    items.push(fb.group({ name: ['ペン'], quantity: [3] }));
    show('push 後:', items.value);
    show('length:', items.length);
    items.at(0).patchValue({ quantity: 5 });
    items.removeAt(1);
    show('removeAt(1) 後:', items.value);
    items.clear();
    show('clear 後:', items.value);
    show('FormArray か:', items instanceof FormArray);
  });
});

1. FormControl:入力欄1つの値と状態

初期値: {"value":"","status":"INVALID","errors":{"required":true}}
setValue 後: {"value":"taro","status":"VALID","errors":null}
reset 後(nonNullable): {"value":""}
reset 後(指定なし): {"value":null}

FormControl は「値」「状態」「エラー」を1つにまとめたオブジェクトです。nonNullable: true を付けると、reset() したときに null ではなく初期値へ戻ります。 型も string | null ではなく string になるので、あとの処理で null の分岐を書かずに済みます。

2. FormGroup:項目をまとめる

value: {"name":"","email":""}
valid: false
name 入力後 valid: true
get で取り出す: ""

FormGroup の valid は、中の項目がすべて有効なときに true になります。個々の項目は form.controls.name で取り出すと型が付きます(form.get('email') でも取り出せますが、戻り値が null になりうる型です)。

3. FormBuilder:短く書く

value: {"name":"","email":"","address":{"zip":"","city":""}}
email の errors: {"email":true}
FormBuilder の reset 後: {"name":null}
NonNullableFormBuilder の reset 後: {"name":"","email":"","address":{"zip":"","city":""}}

fb.group({ name: ['', Validators.required] }) の配列は「初期値、バリデータ」の順です。NonNullableFormBuilder で作ると、すべての項目が nonNullable: true になります。 FormBuilder で作ったフォームは reset() で null になり、NonNullableFormBuilder なら初期値に戻ることが出力から分かります。新しく書くフォームは NonNullableFormBuilder を基本にすると、型と挙動がそろいます。

4. FormArray:行を増減する

push 後: [{"name":"ノート","quantity":1},{"name":"ペン","quantity":3}]
length: 2
removeAt(1) 後: [{"name":"ノート","quantity":5}]
clear 後: []
FormArray か: true
操作 メソッド
末尾に追加 items.push(control)
削除 items.removeAt(index)
全削除 items.clear()
i 番目を取り出す items.at(index)
行数 items.length

型付きフォームで、書き間違いをビルド時に見つける

型付きフォームでは、項目名や値の型の違いがコンパイルエラーになります。次のファイルを npx tsc --noEmit -p tsconfig.app.json にかけた結果です。

import { FormControl, FormGroup } from '@angular/forms';

const form = new FormGroup({
  name: new FormControl('', { nonNullable: true }),
  age: new FormControl(20, { nonNullable: true }),
});

form.controls.age.setValue('20');
form.patchValue({ nama: 'taro' });
const upper: string = form.value.name.toUpperCase();
const raw: string = form.getRawValue().name.toUpperCase();
form.controls.email;
form.get('email');

export { form, upper, raw };
src/app/typed.ts(8,28): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
src/app/typed.ts(9,19): error TS2353: Object literal may only specify known properties, and 'nama' does not exist in type 'Partial<{ name: string; age: number; }>'.
src/app/typed.ts(10,23): error TS18048: 'form.value.name' is possibly 'undefined'.
src/app/typed.ts(12,15): error TS2339: Property 'email' does not exist on type '{ name: FormControl<string>; age: FormControl<number>; }'.
行 見つかった内容
8行目 数値の項目に文字列を入れようとした
9行目 patchValue の項目名の打ち間違い(nama)
10行目 form.value.name は undefined になりうる型(無効化された項目は value に含まれないため)。11行目の getRawValue().name はエラーにならない
12行目 存在しない項目 controls.email

13行目の form.get('email') はエラーになっていません。文字列で指定する get() より、form.controls.xxx で書くほうが型の助けを受けられます。

コピペで動く完成フォーム(会員登録)

ここまでの要素をすべて使ったフォームです。ユーザー名(非同期の重複チェックつき)、メール、パスワードと確認用(相関チェック)、備品の行(FormArray)、同意チェックを持ちます。

npx @angular/cli@latest new app --skip-git --defaults --style=css --ssr=false
cd app

validators.ts(カスタム・相関・非同期バリデータ)

import {
  AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn,
} from '@angular/forms';

// カスタムバリデータ:指定した語を含んでいたらエラー
export function forbiddenWord(word: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = String(control.value ?? '');
    return value.toLowerCase().includes(word.toLowerCase())
      ? { forbiddenWord: { word, actual: value } }
      : null;
  };
}

// 項目間の相関チェック:FormGroup に付けて、2つの項目が同じ値かを確認する
export function sameValue(a: string, b: string): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const va = group.get(a)?.value;
    const vb = group.get(b)?.value;
    return va === vb ? null : { sameValue: { fields: [a, b] } };
  };
}

// 非同期バリデータ:サーバーへの問い合わせの代わりに、300ms 後に結果を返す
const TAKEN = ['admin', 'taro'];
export function userNameTaken(delayMs = 300): AsyncValidatorFn {
  return async (control: AbstractControl): Promise<ValidationErrors | null> => {
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return TAKEN.includes(control.value) ? { userNameTaken: true } : null;
  };
}

signup-form.ts(フォーム本体)

import { JsonPipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { forbiddenWord, sameValue, userNameTaken } from './validators';

@Component({
  selector: 'app-signup-form',
  imports: [ReactiveFormsModule, JsonPipe],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <h1>会員登録</h1>

      <label>ユーザー名 <input id="userName" formControlName="userName" /></label>
      @if (form.controls.userName; as c) {
        @if (c.pending) {
          <p class="hint">確認中...</p>
        }
        @if (c.invalid && (c.touched || c.dirty)) {
          @if (c.hasError('required')) {
            <p class="error">ユーザー名を入力してください。</p>
          }
          @if (c.errors?.['minlength']; as e) {
            <p class="error">{{ e.requiredLength }}文字以上で入力してください(現在{{ e.actualLength }}文字)。</p>
          }
          @if (c.hasError('forbiddenWord')) {
            <p class="error">「{{ c.errors?.['forbiddenWord'].word }}」は使えません。</p>
          }
          @if (c.hasError('userNameTaken')) {
            <p class="error">このユーザー名は使われています。</p>
          }
        }
      }

      <label>メール <input id="email" type="email" formControlName="email" /></label>
      @if (form.controls.email; as c) {
        @if (c.invalid && (c.touched || c.dirty)) {
          @if (c.hasError('required')) {
            <p class="error">メールアドレスを入力してください。</p>
          } @else if (c.hasError('email')) {
            <p class="error">メールアドレスの形式で入力してください。</p>
          }
        }
      }

      <fieldset formGroupName="passwords">
        <legend>パスワード</legend>
        <label>パスワード <input id="password" type="password" formControlName="password" /></label>
        <label>確認用 <input id="confirm" type="password" formControlName="confirm" /></label>
        @if (form.controls.passwords; as g) {
          @if (g.hasError('sameValue') && g.controls.confirm.touched) {
            <p class="error">パスワードが一致していません。</p>
          }
        }
      </fieldset>

      <fieldset formArrayName="items">
        <legend>希望する備品</legend>
        @for (item of form.controls.items.controls; track item; let i = $index) {
          <div class="row" [formGroupName]="i">
            <input placeholder="品名" formControlName="name" />
            <input type="number" formControlName="quantity" />
            <button type="button" class="remove" (click)="removeItem(i)">削除</button>
            @if (item.controls.quantity.errors?.['min']; as e) {
              <span class="error">{{ e.min }}以上にしてください。</span>
            }
          </div>
        }
        <button type="button" id="add" (click)="addItem()">行を追加</button>
      </fieldset>

      <label><input id="agree" type="checkbox" formControlName="agree" /> 利用規約に同意する</label>

      <button type="submit" id="submit">登録</button>
      <p id="status">status: {{ form.status }}</p>
    </form>

    @if (result(); as r) {
      <pre id="result">{{ r | json }}</pre>
    }
  `,
  styles: `
    label, .row { display: block; margin: 8px 0; }
    .error { color: #c62828; margin: 2px 0; }
    input.ng-invalid.ng-touched { border-color: #c62828; }
  `,
})
export class SignupForm {
  private readonly fb = inject(NonNullableFormBuilder);

  readonly form = this.fb.group({
    userName: this.fb.control('', {
      validators: [Validators.required, Validators.minLength(3), forbiddenWord('test')],
      asyncValidators: [userNameTaken()],
    }),
    email: ['', [Validators.required, Validators.email]],
    passwords: this.fb.group(
      {
        password: ['', [Validators.required, Validators.minLength(8)]],
        confirm: ['', Validators.required],
      },
      { validators: sameValue('password', 'confirm') },
    ),
    items: this.fb.array([this.createItem()]),
    agree: [false, Validators.requiredTrue],
  });

  protected readonly result = signal<ReturnType<typeof this.form.getRawValue> | null>(null);

  private createItem() {
    return this.fb.group({
      name: ['', Validators.required],
      quantity: [1, [Validators.required, Validators.min(1)]],
    });
  }

  addItem(): void {
    this.form.controls.items.push(this.createItem());
  }

  removeItem(index: number): void {
    this.form.controls.items.removeAt(index);
  }

  submit(): void {
    if (this.form.invalid || this.form.pending) {
      this.form.markAllAsTouched(); // 触っていない項目のエラーもまとめて表示する
      return;
    }
    this.result.set(this.form.getRawValue());
  }
}

app.ts

ng new が作る app.html・app.css・app.spec.ts は削除し、app.ts を次の内容にします。

import { Component } from '@angular/core';
import { SignupForm } from './signup-form';

@Component({
  selector: 'app-root',
  imports: [SignupForm],
  template: `<app-signup-form />`,
})
export class App {}

テンプレートとクラスの対応

クラス側 テンプレート側 備考
form(FormGroup) [formGroup]="form" form 要素に付ける。角かっこ付きでインスタンスを渡す
userName(FormControl) formControlName="userName" 名前の文字列で指定
passwords(入れ子の FormGroup) formGroupName="passwords" 中の formControlName はこのグループ内の名前
items(FormArray) formArrayName="items" —
items の i 番目(FormGroup) [formGroupName]="i" 数値の添字を渡すので角かっこ付き
送信 (ngSubmit)="submit()" —

@for では form.controls.items.controls を回し、track item でコントロールのインスタンスを目印にしています。@if (form.controls.userName; as c) は、長い参照に短い別名 c を付けるための書き方です。

画面で操作した結果

コンポーネントをテストで描画し、入力・フォーカス移動・クリックを順に行ったときの、エラーメッセージ(.error の文言)の記録です。

1. 初期表示のエラー: []
2. メール欄を何も入れずに離れる: ["メールアドレスを入力してください。"]
3. メール abc・ユーザー名 ab: ["3文字以上で入力してください(現在2文字)。","メールアドレスの形式で入力してください。"]
4. ユーザー名 taro(直後): ["確認中..."] PENDING
5. 400ms 後: ["このユーザー名は使われています。","メールアドレスの形式で入力してください。"]
6. 行を2回追加 → 行数: 3
7. 2行目を削除 → 行数: 2
8. 入力途中のまま登録: ["このユーザー名は使われています。","メールアドレスの形式で入力してください。"]
    status: INVALID

ng build の出力を Chromium で開き、正しい値を入れて登録したときの結果です。

確認用が違うとき: ["パスワードが一致していません。"]
数量 0 のとき: ["1以上にしてください。"]
status: VALID
userName の class: ng-dirty ng-touched ng-valid
{
  "userName": "saburo",
  "email": "saburo@example.com",
  "passwords": {
    "password": "abc12345",
    "confirm": "abc12345"
  },
  "items": [
    { "name": "ノート", "quantity": 1 },
    { "name": "ペン", "quantity": 3 }
  ],
  "agree": true
}

(items の各行は、掲載用に1行へまとめています。)入力欄には状態に応じて ng-valid・ng-invalid・ng-touched・ng-dirty などの CSS クラスが付くので、input.ng-invalid.ng-touched のようなセレクタで枠の色を変えられます。

touched と dirty を使ったエラー表示

初期表示(上の記録の1)でエラーが1つも出ていないのは、条件を c.invalid && (c.touched || c.dirty) にしているためです。空のフォームは最初から invalid ですが、利用者がまだ何もしていない段階で赤い文言を並べないようにしています。

条件 エラーが出るタイミング 向いている場面
c.invalid 初期表示から 編集画面で、保存済みの値の問題を知らせたいとき
c.invalid && c.touched 入力欄から離れたあと 入力中は静かにしておきたいとき
c.invalid && c.dirty 1文字でも入力したあと 入力しながら確認したいとき
c.invalid && (c.touched || c.dirty) どちらか早いほう 迷ったときの基本形

プログラムから setValue() しても dirty にはなりません。 touched・dirty は利用者の操作を表す印で、コードから変えたいときは markAsTouched()・markAsDirty() を呼びます。

初期: {"touched":false,"dirty":false,"pristine":true}
setValue 後: {"touched":false,"dirty":false,"pristine":true}
markAsTouched + markAsDirty 後: {"touched":true,"dirty":true,"pristine":false}
reset 後: {"touched":false,"dirty":false,"pristine":true}

送信ボタンでは、form.markAllAsTouched() を呼んでから return します(記録の8)。触っていない項目も touched になり、条件を変えなくてもエラーがまとめて表示されます。

Validators 全種類と、errors のキー・中身の一覧

Validators クラスの静的メソッドは、Angular 22.2.1 の型定義で11個です。それぞれにエラーになる値を入れ、control.errors を JSON.stringify した結果を表にしました。

バリデータ 試した値 errors の中身 表示に使えるプロパティ
Validators.required "" {"required":true} —
Validators.requiredTrue false {"required":true} —(キーは required)
Validators.email "abc" {"email":true} —
Validators.min(1) 0 {"min":{"min":1,"actual":0}} min、actual
Validators.max(10) 11 {"max":{"max":10,"actual":11}} max、actual
Validators.minLength(3) "ab" {"minlength":{"requiredLength":3,"actualLength":2}} requiredLength、actualLength
Validators.maxLength(5) "abcdef" {"maxlength":{"requiredLength":5,"actualLength":6}} requiredLength、actualLength
Validators.pattern(/^[0-9]+$/) "12a" {"pattern":{"requiredPattern":"/^[0-9]+$/","actualValue":"12a"}} requiredPattern、actualValue
Validators.pattern('[a-z]+') "ABC" {"pattern":{"requiredPattern":"^[a-z]+$","actualValue":"ABC"}} 同上
Validators.nullValidator "" null —(何も検証しない)
Validators.compose([minLength(5), pattern('[a-z]+')]) "ab1" {"minlength":{...},"pattern":{...}}(両方のエラーをまとめて返す) —
Validators.composeAsync([...]) — 非同期バリデータの配列を受け取る compose(型定義の引数は AsyncValidatorFn の配列) —

表から読み取れる、つまずきやすい点です。

  • minLength / maxLength のキーは小文字の minlength / maxlength です。c.hasError('minLength') と書くと false のままになります。
  • requiredTrue のキーは required です。チェックボックスの同意に使ったときも hasError('required') で判定します。
  • pattern に文字列を渡すと、前後に ^ と $ が自動で付きます([a-z]+ が ^[a-z]+$ になっています)。正規表現リテラルを渡した場合はそのまま使われます。

空の値のときの挙動

required + minLength(3) ("")       {"required":true}
email ("")                         null
minLength(3) ("")                  null
required ("  ")                    null
確認できたこと 対応
email・minLength は、空文字ではエラーにならない 必須にしたい項目には Validators.required を一緒に付ける
required は、空白だけの文字列 " " を通す 空白だけを弾きたいときは、カスタムバリデータで trim() した結果が空かどうかを判定する
複数のバリデータを配列で渡すと、エラーはまとめて errors に入る 空文字では required だけが入る(minLength は空を対象にしないため)

カスタム・相関・非同期バリデータ

validators.ts の3つを、単体で動かした結果です。

forbiddenWord: {"forbiddenWord":{"word":"test","actual":"my-test-user"}}
sameValue(不一致)group.errors: {"sameValue":{"fields":["password","confirm"]}}
sameValue(不一致)confirm.errors: null
sameValue(一致)group.errors: null
非同期 直後 status: "PENDING"
非同期 完了後: {"status":"INVALID","errors":{"userNameTaken":true}}
値を変えた直後 status: "PENDING"
完了後: {"status":"VALID","errors":null}
同期エラーがあるとき status: "INVALID"

カスタムバリデータ:関数が null かオブジェクトを返す

バリデータは「コントロールを受け取り、問題がなければ null、あれば { キー: 中身 } を返す関数」です。forbiddenWord('test') のように引数を受け取ってバリデータを返す関数にしておくと、条件を変えて使い回せます。中身に word を入れておけば、メッセージに「『test』は使えません」と埋め込めます。

項目間の相関チェック:FormGroup に付ける

2つの項目を比べるバリデータは、個々の項目ではなく、両方を含む FormGroup に付けます。 fb.group({...}, { validators: sameValue('password', 'confirm') }) の第2引数です。出力のとおり、エラーはグループの errors に入り、confirm 自身の errors は null のままです。そのためテンプレートでは g.hasError('sameValue') とグループに対して判定しています。表示のタイミングは、確認用の欄から離れたあと(g.controls.confirm.touched)にしました。

非同期バリデータ:PENDING のあいだは待つ

AsyncValidatorFn は Promise か Observable を返します。結果が出るまで status は PENDING になり、pending が true になります。

確認できたこと フォーム側の対応
値が変わるたびに PENDING に戻る @if (c.pending) で「確認中...」を表示する
同期バリデータにエラーがあると、非同期バリデータは実行されない(すぐ INVALID になる) 文字数などの軽い確認は同期側に置くと、サーバーへの問い合わせを減らせる
PENDING は INVALID とは別の状態 送信時は form.invalid || form.pending の両方を確認する

setValue と patchValue、value と getRawValue の違い

setValue と patchValue

const fb = TestBed.inject(NonNullableFormBuilder);
const form = fb.group({ name: ['taro'], email: ['taro@example.com'], age: [20] });

form.patchValue({ name: 'hanako' });
show('patchValue({name}) 後:', form.value);
form.setValue({ name: 'jiro', email: 'jiro@example.com', age: 30 });
show('setValue(全項目) 後:', form.value);
tryIt('setValue で項目が足りない:', () => form.setValue({ name: 'x' } as never));
tryIt('setValue で余分な項目:', () =>
  form.setValue({ name: 'x', email: 'y', age: 1, extra: true } as never));
tryIt('patchValue で余分な項目:', () => form.patchValue({ name: 'x', extra: true } as never));
show('最後の value:', form.value);
patchValue({name}) 後: {"name":"hanako","email":"taro@example.com","age":20}
setValue(全項目) 後: {"name":"jiro","email":"jiro@example.com","age":30}
setValue で項目が足りない: NG01002: Must supply a value for form control with name: 'email'. Find more at https://v22.angular.dev/errors/NG01002
setValue で余分な項目: NG01001: Cannot find form control with name: 'extra'
patchValue で余分な項目: エラーなし
最後の value: {"name":"x","email":"y","age":1}

(show は JSON.stringify して出力する関数、tryIt は例外のメッセージを出力する関数です。as never は、型チェックを外して実行時の挙動を見るために付けています。)

setValue patchValue
渡す値 全項目が必要 一部だけでよい
項目が足りない NG01002 で止まる 渡した項目だけ更新
余分な項目がある NG01001 で止まる 無視される
向いている場面 API から取得した1件を丸ごと入れる(過不足に気づける) 一部の項目だけ更新する

最後の行に注目してください。「余分な項目」で NG01001 が出たケースでも、name・email・age は書き換わっています。 例外が出る前に、存在する項目への代入は済んでいるためです。型付きフォームなら、as never を付けない限りこれらはコンパイル時に見つかります。

value と getRawValue

const form = fb.group({ id: [100], name: ['taro'], email: ['taro@example.com'] });
form.controls.id.disable();
show('value:', form.value);
show('getRawValue():', form.getRawValue());
show('status:', form.status);
form.disable();
show('全体を disable した value:', form.value);
show('全体を disable した status:', form.status);
value: {"name":"taro","email":"taro@example.com"}
getRawValue(): {"id":100,"name":"taro","email":"taro@example.com"}
status: "VALID"
全体を disable した value: {"id":100,"name":"taro","email":"taro@example.com"}
全体を disable した status: "DISABLED"
form.value form.getRawValue()
無効化(disable())した項目 含まれない 含まれる
型 各項目が省略可能(Partial) 全項目がそろった型
向いている場面 無効化した項目を送りたくないとき 送信データを組み立てるとき(ID を表示専用にしている場合など)

また、フォーム全体を disable() したときの value には全項目が入ることも確認できました。完成フォームの submit() では、型がそろう getRawValue() を使っています。

valueChanges と emitEvent

値の変化は valueChanges(Observable)で受け取れます。setValue('b', { emitEvent: false }) のように指定した変更は流れません。'a'・'b'(emitEvent: false)・'c' の順に入れたときの記録です。

valueChanges に流れた値: ["a","c"]

よく出るエラーと直し方

それぞれ最小のコンポーネントで再現し、メッセージを記録しました。

@Component({
  selector: 'app-no-parent',
  imports: [ReactiveFormsModule],
  template: `<input formControlName="name" />`,
})
class NoParent {}

@Component({
  selector: 'app-wrong-name',
  imports: [ReactiveFormsModule],
  template: `<form [formGroup]="form"><input formControlName="nama" /></form>`,
})
class WrongName {
  form = new FormGroup({ name: new FormControl('') });
}

@Component({
  selector: 'app-no-instance',
  imports: [ReactiveFormsModule],
  template: `<form [formGroup]="form"><input formControlName="name" /></form>`,
})
class NoInstance {
  form!: FormGroup;
}

@Component({
  selector: 'app-no-accessor',
  imports: [ReactiveFormsModule],
  template: `<form [formGroup]="form"><div formControlName="name"></div></form>`,
})
class NoAccessor {
  form = new FormGroup({ name: new FormControl('') });
}
【親の formGroup がない】
NG01050: formControlName must be used with a parent formGroup or formArray directive. You'll want to add a formGroup/formArray
      directive and pass it an existing FormGroup/FormArray instance (you can create one in your class).
      Affected Form Control name: "name"
【formControlName の名前が違う】
Cannot find control with name: 'nama'
【formGroup に渡す値が入っていない】
NG01052: formGroup expects a FormGroup instance. Please pass one in.
【input 以外の要素に formControlName】
NG01203: No value accessor for form control name: 'name'. Find more at https://v22.angular.dev/errors/NG01203

(NG01050・NG01052 のメッセージに続く記述例の部分は省いています。)

ReactiveFormsModule を imports から外したときは、ビルドの段階で次のエラーになります。

✘ [ERROR] NG8002: Can't bind to 'formGroup' since it isn't a known property of 'form'. Find more at https://next.angular.dev/errors/NG8002 [plugin angular-compiler]

    src/app/signup-form.ts:10:10:
      10 │     <form [formGroup]="form" (ngSubmit)="submit()">
         ╵           ~~~~~~~~~~~~~~~~~~
エラー 原因 直し方
NG01050: formControlName must be used with a parent formGroup formControlName を付けた要素の外側に [formGroup](または formArrayName)がない 外側の form 要素に [formGroup]="form" を付ける
Cannot find control with name: 'nama' formControlName の文字列がクラス側のキーと違う キー名をそろえる。入れ子の場合は formGroupName の位置も確認する
NG01052: formGroup expects a FormGroup instance [formGroup] に渡したプロパティが、描画時点でまだ空 フィールドの初期化子でフォームを作る(form = this.fb.group(...))
NG01203: No value accessor input・select・textarea 以外の要素に formControlName を付けた input などの入力要素に付け直す
NG8002: Can't bind to 'formGroup' ReactiveFormsModule を imports に入れていない コンポーネントの imports: [ReactiveFormsModule] に追加する
NG01002: Must supply a value for form control setValue に全項目を渡していない 全項目を渡すか、patchValue に変える
NG01001: Cannot find form control with name setValue に存在しない項目を渡した 余分な項目を除くか、patchValue に変える

よくある質問

Q. FormBuilder と NonNullableFormBuilder はどう違いますか? A. reset() したときの値と型が違います。出力のとおり、FormBuilder で作った項目は reset() で null になり、NonNullableFormBuilder なら初期値に戻ります。型も string | null ではなく string になります。

Q. エラーメッセージが初期表示から出てしまいます。 A. 表示条件を c.invalid だけにしていると、空の必須項目は最初からエラーになります。c.invalid && (c.touched || c.dirty) に変え、送信時には form.markAllAsTouched() を呼ぶと、操作した項目と送信時にだけ表示されます。

Q. FormArray の @for で track には何を指定すればよいですか? A. この記事ではコントロールのインスタンス(track item)を指定し、行の追加・削除が正しく反映されることを確認しました。

まとめ

  • FormControl(1項目)→ FormGroup(まとまり)→ FormArray(増減する行)を、NonNullableFormBuilder で短く型付きで作る
  • テンプレートは [formGroup]・formControlName・formGroupName・formArrayName で結びつけ、FormArray の行は [formGroupName]="i"
  • エラー表示は c.invalid && (c.touched || c.dirty)、送信時は markAllAsTouched()
  • errors のキーは minlength・maxlength(小文字)、requiredTrue は required。email と minLength は空文字を通すので required と組み合わせる
  • 相関チェックは FormGroup に付け、エラーはグループの errors に入る。非同期バリデータは同期エラーがないときだけ動き、確認中は PENDING
  • setValue は全項目必須(NG01002 / NG01001)、patchValue は一部だけ更新。value は無効化した項目を含まず、getRawValue() は含む
  • NG01050 は、formControlName の外側に [formGroup] がないときに出る

参考・出典

  • Angular 日本語ドキュメント「リアクティブフォーム」 https://angular.jp/guide/forms/reactive-forms
  • @angular/forms 22.2.1 の型定義(Validators クラスの静的メソッド一覧)

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

(記載例)出典:株式会社RJC「Angularリアクティブフォームの使い方|FormGroup・バリデーション」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?