FormArray は、数が変わる入力欄の並び(明細の行など)をまとめて扱う入れ物です。 1行を FormGroup にして FormArray に入れ、push() で行を足し、removeAt(i) で消します。行の数が決まっていない入力は、FormArray で作ります。

lines = this.fb.array<LineForm>([this.createLine()], [Validators.required, uniqueNames]);

addLine()          { this.lines.push(this.createLine()); }   // 行を追加
removeLine(i: number) { this.lines.removeAt(i); }             // 行を削除

この記事の完成コード(明細入力)を Chromium で操作したときの、画面の値です。

-- 行を追加 ×2 し、2行目 みかん 5×80、3行目 りんご 1×120
   rows=3 total=880
     [0] りんご | 3 | 120 | 360 | 品名が重複しています
     [1] みかん | 5 | 80 | 400 |
     [2] りんご | 1 | 120 | 120 | 品名が重複しています
-- 3行目を削除して保存
   rows=2 total=760
   SAVE [{"name":"りんご","quantity":3,"unitPrice":120},{"name":"みかん","quantity":5,"unitPrice":80}]
-- 全部削除
   rows=0 total=0 arrayErr=明細は1行以上必要です
やりたいこと 書き方
行を末尾に追加 lines.push(group)
途中に挿入 lines.insert(i, group)
行を削除 lines.removeAt(i)
全部消す lines.clear()
行を差し替え lines.setControl(i, group)
i 行目を取る lines.at(i)(負の数は末尾から)
行数 lines.length
無効な行も含めた値 lines.getRawValue()

確認環境:Angular 22.2.2(@angular/core・@angular/forms・@angular/cdk・@angular/cli)、TypeScript 6.0.3、Node.js 24.21.0(Linux)。この記事の完成コード(リアクティブフォーム版と Signal Forms 版)を ng build し、Chromium(Playwright 1.56.1)で行の追加・削除・入力・ドラッグでの並び替え・保存を操作して、画面とコンソールの値を記録しました。patchValue・setValue などの動きも同じビルドの中で確かめています。API の説明は angular.dev のドキュメントで確かめました(確認日 2026年10月13日)。

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

(記載例)出典:株式会社RJC「Angular FormArrayの使い方|行の追加・削除と入力チェックの完成コード」

完成コード:明細入力(品名・数量・単価・小計・合計)

1つのコンポーネントにまとめた完成コードです。standalone コンポーネント(NgModule を使わない書き方。v17 以降の ng new の既定)で、そのまま貼り付けて動きます。

  • 行ごとに「品名は必須」「数量は1以上」「単価は0以上」をチェック
  • 表全体で「1行以上」「品名の重複禁止」をチェック
  • 小計・合計はその場で計算
  • 行は CDK のドラッグで並び替え
  • 保存のときに、まだ触っていない欄のエラーもまとめて出す
// invoice-form.ts
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { DecimalPipe } from '@angular/common';
import {
  FormArray, FormControl, FormGroup, NonNullableFormBuilder,
  ReactiveFormsModule, ValidatorFn, Validators,
} from '@angular/forms';
import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList } from '@angular/cdk/drag-drop';
import { map, startWith } from 'rxjs';

export interface Line { name: string; quantity: number; unitPrice: number; }

type LineForm = FormGroup<{
  name: FormControl<string>;
  quantity: FormControl<number>;
  unitPrice: FormControl<number>;
}>;

/** 品名の重複を見つける(FormArray 全体のバリデーター) */
export const uniqueNames: ValidatorFn = (control) => {
  const rows = (control as FormArray<LineForm>).controls;
  const names = rows.map(r => r.controls.name.value.trim());
  const dupIndexes = names
    .map((n, i) => (n !== '' && names.filter(x => x === n).length > 1 ? i : -1))
    .filter(i => i >= 0);
  return dupIndexes.length ? { duplicateName: { indexes: dupIndexes } } : null;
};

@Component({
  selector: 'app-invoice-form',
  imports: [ReactiveFormsModule, DecimalPipe, CdkDropList, CdkDrag, CdkDragHandle],
  template: `
<form [formGroup]="form" (ngSubmit)="save()">
  <table formArrayName="lines">
    <thead><tr><th></th><th>品名</th><th>数量</th><th>単価</th><th>小計</th><th></th></tr></thead>
    <tbody cdkDropList (cdkDropListDropped)="drop($event)">
      @for (line of lines.controls; track line; let i = $index) {
        <tr [formGroupName]="i" cdkDrag class="row">
          <td><span cdkDragHandle class="handle">≡</span></td>
          <td>
            <input formControlName="name" class="name">
            @if (showError(line.controls.name)) {
              @if (line.controls.name.hasError('required')) { <small class="err">品名は必須です</small> }
            }
            @if (isDuplicate(i)) { <small class="err dup">品名が重複しています</small> }
          </td>
          <td>
            <input type="number" formControlName="quantity" class="qty">
            @if (showError(line.controls.quantity)) {
              @if (line.controls.quantity.hasError('required')) { <small class="err">数量は必須です</small> }
              @if (line.controls.quantity.hasError('min')) { <small class="err">1以上</small> }
            }
          </td>
          <td>
            <input type="number" formControlName="unitPrice" class="price">
            @if (showError(line.controls.unitPrice) && line.controls.unitPrice.hasError('min')) {
              <small class="err">0以上</small>
            }
          </td>
          <td class="sub">{{ subtotal(i) | number }}</td>
          <td><button type="button" class="del" (click)="removeLine(i)">削除</button></td>
        </tr>
      }
    </tbody>
  </table>

  @if (lines.hasError('required') && (lines.touched || submitted)) {
    <p class="err rows">明細は1行以上必要です</p>
  }
  <p>合計:<strong class="total">{{ total() | number }}</strong> 円({{ lines.length }} 行)</p>
  <button type="button" class="add" (click)="addLine()">行を追加</button>
  <button type="submit" class="save">保存</button>
</form>`,
  styles: `.err{color:#c00;display:block} .handle{cursor:move}`,
})
export class InvoiceForm {
  private fb = inject(NonNullableFormBuilder);
  submitted = false;

  form = this.fb.group({
    lines: this.fb.array<LineForm>([this.createLine()], [Validators.required, uniqueNames]),
  });

  get lines() { return this.form.controls.lines; }

  /** 行の値(Signal)。合計の計算に使う */
  private rows = toSignal(
    this.lines.valueChanges.pipe(startWith(null), map(() => this.lines.getRawValue())),
    { requireSync: true },
  );
  total = computed(() =>
    this.rows().reduce((sum, r) => sum + (r.quantity ?? 0) * (r.unitPrice ?? 0), 0));

  createLine(line: Partial<Line> = {}): LineForm {
    return this.fb.group({
      name: [line.name ?? '', Validators.required],
      quantity: [line.quantity ?? 1, [Validators.required, Validators.min(1)]],
      unitPrice: [line.unitPrice ?? 0, [Validators.required, Validators.min(0)]],
    });
  }

  addLine() { this.lines.push(this.createLine()); }
  removeLine(i: number) { this.lines.removeAt(i); this.lines.markAsTouched(); }

  subtotal(i: number) {
    const r = this.rows()[i];
    return r ? (r.quantity ?? 0) * (r.unitPrice ?? 0) : 0;
  }

  isDuplicate(i: number) {
    return (this.lines.errors?.['duplicateName']?.indexes as number[] | undefined)?.includes(i) ?? false;
  }

  showError(c: FormControl) { return c.invalid && (c.touched || this.submitted); }

  /** サーバーのデータで行を作り直す(patchValue では行数が増えない) */
  load(data: Line[]) {
    this.lines.clear({ emitEvent: false });
    data.forEach(d => this.lines.push(this.createLine(d), { emitEvent: false }));
    this.lines.updateValueAndValidity();
  }

  drop(e: CdkDragDrop<unknown>) {
    const c = this.lines.at(e.previousIndex);
    this.lines.removeAt(e.previousIndex, { emitEvent: false });
    this.lines.insert(e.currentIndex, c);
  }

  save() {
    this.submitted = true;
    this.form.markAllAsTouched();
    if (this.form.invalid) return;
    console.log('SAVE', JSON.stringify(this.form.getRawValue().lines));
  }
}

リアクティブフォームの基本(FormGroup・formControlName)は、関連記事「Angularリアクティブフォームの使い方|FormGroup・バリデーション」で扱っています。

Playwright で操作した結果

完成コードを、上から順に操作したときの記録です。[i] は行番号で、「品名 | 数量 | 単価 | 小計 | エラー」の順に並べています。

-- 保存(空のまま)
   rows=1 total=0
     [0]  | 1 | 0 | 0 | 品名は必須です
-- 1行目に入力(りんご 3 × 120)
   rows=1 total=360
     [0] りんご | 3 | 120 | 360 |
-- 2行目の数量を空にする / 0 にする
   rows=3 total=480
     [1] みかん |  | 80 | 0 | 数量は必須です
   value: [ … {"name":"みかん","quantity":null,"unitPrice":80} … ]
   rows=3 total=480
     [1] みかん | 0 | 80 | 0 | 1以上
-- 行を3つ追加して並び替え(ドラッグで1行目を3行目へ)
     [0] B | 1 | 0 | 0 |
     [1] C | 1 | 0 | 0 |
     [2] A | 1 | 0 | 0 |
   SAVE [{"name":"B",…},{"name":"C",…},{"name":"A",…}]
  • 保存ボタンで markAllAsTouched() が動き、まだ触っていない品名の欄にもエラーが出ました
  • 重複している2行の両方に「品名が重複しています」が出ました
  • 全部の行を消すと、表の下に「明細は1行以上必要です」が出ました
  • ドラッグで並び替えた順のまま、保存の値も並びました

型付きフォームで書く:FormArray<FormGroup<…>> と fb.nonNullable

v14 から、フォームの値に型が付きます(型付きフォーム)。FormArray は、中に入れる1行の型を型引数に書きます。

type LineForm = FormGroup<{
  name: FormControl<string>;
  quantity: FormControl<number>;
  unitPrice: FormControl<number>;
}>;

lines: FormArray<LineForm>;
// lines.at(0).controls.name.value の型は string
// lines.getRawValue() の型は { name: string; quantity: number; unitPrice: number }[]
作り方 FormControl の値の型 reset() 後の値
new FormBuilder().group({ name: '' }) string | null null
fb.nonNullable.group({ name: '' }) string 最初の値('')
inject(NonNullableFormBuilder).group(…) string 最初の値

NonNullableFormBuilder は、すべての部品に { nonNullable: true } を付けるのと同じ、と angular.dev で説明されています。手元でも、品名を「初期」で作って「変更」に書き換え、reset() すると、値は null ではなく「初期」に戻りました。

nonNullable の reset 後: [{"name":"初期","quantity":1,"unitPrice":0}]

数字の欄を空にすると null になる

型は FormControl<number> でも、<input type="number"> を空にすると、値は null になりました(上の記録の "quantity":null)。nonNullable は「reset() で null に戻さない」設定で、入力欄から入る null は防ぎません。

対策 書き方
必須にしてエラーで止める Validators.required
計算で null を0として扱う (r.quantity ?? 0) * (r.unitPrice ?? 0)

完成コードは両方を入れています。Validators の一覧と自作のバリデーターは、関連記事「Angularカスタムバリデーターの作り方|非同期・相関チェック」で扱っています。

行ごとの入力チェックとエラー表示

行ごとのチェックは、1行を作る関数(createLine)の中で、各欄に Validators を付けます。

createLine(line: Partial<Line> = {}): LineForm {
  return this.fb.group({
    name: [line.name ?? '', Validators.required],
    quantity: [line.quantity ?? 1, [Validators.required, Validators.min(1)]],
    unitPrice: [line.unitPrice ?? 0, [Validators.required, Validators.min(0)]],
  });
}

テンプレートでは、@for の変数 line(その行の FormGroup)から欄を取り出して、エラーを見ます。

@for (line of lines.controls; track line; let i = $index) {
  <tr [formGroupName]="i">
    <input formControlName="name">
    @if (line.controls.name.touched && line.controls.name.hasError('required')) {
      <small>品名は必須です</small>
    }
  </tr>
}
ポイント 理由
lines.controls を @for で回す lines.value を回すと、値の配列なので欄の状態(エラー・touched)が取れない
track line(行の部品そのもの) 行を消しても、残りの行の DOM がそのまま使われる
[formGroupName]="i" formArrayName="lines" の i 番目の行に、中の formControlName をつなぐ
line.controls.name で取る 型が付く。line.get('name') は文字列で探すので、名前の打ち間違いがコンパイルで見つからない

@for の書き方は、関連記事「Angular @if・@for・@switchの使い方|ngIfとの対応表」で扱っています。

FormArray 全体のバリデーター:1行以上・重複禁止

FormArray そのものにもバリデーターを付けられます。fb.array() の第2引数に書きます。

lines: this.fb.array<LineForm>([this.createLine()], [Validators.required, uniqueNames]),

1行以上:minLength(1) では止まらない

空の FormArray に、それぞれのバリデーターを付けたときの errors です。

空の配列 required:      {"required":true}
空の配列 minLength(1):  null
1行 minLength(2):       {"minlength":{"requiredLength":2,"actualLength":1}}

Validators.minLength(1) は、空の FormArray をエラーにしませんでした。 angular.dev の Validators の説明でも、minLength は長さが0の値(空の文字列・空の配列)にはチェックを動かさず、空を認めないときは required を使う、と書かれています。1行以上を求めるときは Validators.required を使います。2行以上なら、required と minLength(2) を両方付けます。

重複禁止:エラーに行番号を入れる

どの行が重複しているかを画面に出すため、エラーの中に行番号(indexes)を入れています。

export const uniqueNames: ValidatorFn = (control) => {
  const rows = (control as FormArray<LineForm>).controls;
  const names = rows.map(r => r.controls.name.value.trim());
  const dupIndexes = names
    .map((n, i) => (n !== '' && names.filter(x => x === n).length > 1 ? i : -1))
    .filter(i => i >= 0);
  return dupIndexes.length ? { duplicateName: { indexes: dupIndexes } } : null;
};
isDuplicate(i: number) {
  return (this.lines.errors?.['duplicateName']?.indexes as number[] | undefined)?.includes(i) ?? false;
}

子の値が変わると、親の FormArray のバリデーターも動き直します。1行目と3行目に「りんご」を入れた時点で、両方の行にエラーが出ました。

チェックの種類 置き場所 エラーの取り方
欄ごと(必須・範囲) 行の FormControl line.controls.name.hasError('required')
行の中の組み合わせ(例:割引は単価以下) 行の FormGroup line.hasError('…')
行どうし(重複・行数・合計の上限) FormArray lines.hasError('…')・lines.errors

合計・小計を計算する

値の変化は valueChanges で受け取れます。toSignal() で Signal(値の変化を知らせる入れ物)にしておくと、computed() で合計を出せます。

private rows = toSignal(
  this.lines.valueChanges.pipe(startWith(null), map(() => this.lines.getRawValue())),
  { requireSync: true },
);
total = computed(() =>
  this.rows().reduce((sum, r) => sum + (r.quantity ?? 0) * (r.unitPrice ?? 0), 0));
  • startWith(null) で、最初の値(1行目の初期値)も流す
  • valueChanges の値ではなく getRawValue() を使う。valueChanges の値は、無効(disabled)にした行が抜け、型も Partial になる

toSignal の使い方は、関連記事「Angular RxJS と Signals の使い分け|toSignal判断フロー」で扱っています。

並び替え:CDK のドラッグと組み合わせる

@angular/cdk/drag-drop の cdkDropList・cdkDrag を行に付け、落としたときに FormArray の順番を入れ替えます。

drop(e: CdkDragDrop<unknown>) {
  const c = this.lines.at(e.previousIndex);
  this.lines.removeAt(e.previousIndex, { emitEvent: false });
  this.lines.insert(e.currentIndex, c);
}

CDK には配列の順番を入れ替える moveItemInArray() もあります。これを lines.controls に使うと、次のようになりました。

moveItemInArray直後 value: A,B,C  controls: B,C,A
updateValueAndValidity後 value: B,C,A

controls の並びは変わっても、value は元の順のままでした。 FormArray は、自分の値をまだ計算し直していないためです。moveItemInArray を使う場合は、続けて lines.updateValueAndValidity() を呼びます。完成コードの removeAt → insert の形なら、insert のときに値が計算し直されます。

ドラッグ&ドロップの細かい設定(リスト間の移動、保存)は、関連記事「Angular ドラッグ&ドロップ|CDKで並び替え・リスト間移動・保存」で扱っています。

落とし穴:patchValue では行数が合わない

サーバーから3件の明細を受け取り、1行しかない FormArray に入れたときの結果です。

patchValue 3件→1行: 1 [{"name":"A","quantity":1,"unitPrice":100}]
patchValue 1件→3行: 3 [{"name":"A","quantity":1,"unitPrice":0},{"name":"y",…},{"name":"z",…}]
setValue 3件→1行   ERROR Error: NG01001: Cannot find form control at index: 1
setValue 1件→3行   ERROR Error: NG01002: Must supply a value for form control at index: 1.
reset 3件→1行:     1 [{"name":"A","quantity":1,"unitPrice":100}]
メソッド データの方が多い データの方が少ない
patchValue 余りは黙って捨てる(行は増えない) 足りない行は前の値のまま
setValue エラー(NG01001) エラー(NG01002)
reset(値) 余りは捨てる 足りない行は初期値に戻る

angular.dev でも、patchValue は「多すぎても少なくても、エラーを出さずに受け付ける」と説明されています。行の数を変えるメソッドではありません。 データに合わせて行を作り直します。

load(data: Line[]) {
  this.lines.clear({ emitEvent: false });
  data.forEach(d => this.lines.push(this.createLine(d), { emitEvent: false }));
  this.lines.updateValueAndValidity();
}

emitEvent: false を付けると、valueChanges が何回流れるかが変わりました。

clear+push×3 の valueChanges 回数: 4
emitEvent:false + updateValueAndValidity の回数: 1

valueChanges で保存や API 呼び出しをしている場合は、1回にまとめた方が無駄な処理が減ります。FormArray を丸ごと差し替える this.form.setControl('lines', this.fb.array(…)) もありますが、その場合はテンプレートや valueChanges の購読が差し替え前の FormArray を見たままにならないかに注意します(完成コードの rows は、最初の lines を購読しています)。

そのほかの注意点

push後 dirty: false   removeAt後 dirty: false
  • push・removeAt では dirty(変更あり)にならない。行の追加・削除だけでも「変更あり」として扱いたいときは、lines.markAsDirty() を呼ぶ
  • 1行の中に id を書くと、行の数だけ同じ id ができる。label の for と組み合わせるときは、'name-' + i のように行番号を付ける

Signal Forms で同じ明細を作る

v22 では、Signal で値を持つ新しいフォーム(Signal Forms、@angular/forms/signals)が安定版になっています。同じ明細を Signal Forms で書いたコードです。

// invoice-signal.ts
import { Component, computed, signal } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { applyEach, form, FormField, FormRoot, min, required, validate } from '@angular/forms/signals';

interface Line { name: string; quantity: number; unitPrice: number; }
interface Invoice { lines: Line[]; }

const newLine = (): Line => ({ name: '', quantity: 1, unitPrice: 0 });

@Component({
  selector: 'app-invoice-signal',
  imports: [FormField, FormRoot, DecimalPipe],
  template: `
<form [formRoot]="f">
  @for (line of f.lines; track line; let i = $index) {
    <div class="row">
      <input [formField]="line.name" class="name">
      <input type="number" [formField]="line.quantity" class="qty">
      <input type="number" [formField]="line.unitPrice" class="price">
      <span class="sub">{{ line.quantity().value() * line.unitPrice().value() | number }}</span>
      <button type="button" class="del" (click)="removeLine(i)">削除</button>
      @if (line.name().touched()) {
        @for (e of line.name().errors(); track e.kind) { <small class="err">{{ e.message }}</small> }
      }
      @if (line.quantity().touched()) {
        @for (e of line.quantity().errors(); track e.kind) { <small class="err">{{ e.message }}</small> }
      }
    </div>
  }
  @for (e of f.lines().errors(); track e.kind) { <p class="err rows">{{ e.message }}</p> }
  <p>合計:<strong class="total">{{ total() | number }}</strong> 円</p>
  <button type="button" class="add" (click)="addLine()">行を追加</button>
  <button type="submit" class="save">保存</button>
</form>`,
})
export class InvoiceSignal {
  model = signal<Invoice>({ lines: [newLine()] });

  f = form(this.model, (p) => {
    validate(p.lines, ({ value }) =>
      value().length === 0 ? { kind: 'minRows', message: '明細は1行以上必要です' } : null);
    applyEach(p.lines, (line) => {
      required(line.name, { message: '品名は必須です' });
      required(line.quantity, { message: '数量は必須です' });
      min(line.quantity, 1, { message: '数量は1以上' });
      min(line.unitPrice, 0, { message: '単価は0以上' });
      validate(line.name, ({ value, valueOf }) => {
        const v = value().trim();
        const same = valueOf(p.lines).filter(l => l.name.trim() === v).length;
        return v && same > 1 ? { kind: 'duplicate', message: '品名が重複しています' } : null;
      });
    });
  }, {
    submission: {
      action: async () => {
        console.log('SAVE', JSON.stringify(this.model().lines));
        return undefined;
      },
    },
  });

  total = computed(() =>
    this.model().lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0));

  addLine() { this.model.update(m => ({ ...m, lines: [...m.lines, newLine()] })); }
  removeLine(i: number) {
    this.model.update(m => ({ ...m, lines: m.lines.filter((_, j) => j !== i) }));
  }
}

同じ操作をした結果は、リアクティブフォーム版と同じでした(重複の2行にエラー、3行目を消して保存、全部消すと「明細は1行以上必要です」)。数字の欄を空にしたときの値も、こちらも null でした。

-- 3行目を削除して保存
   rows=2 total=760
   SAVE [{"name":"りんご","quantity":3,"unitPrice":120},{"name":"みかん","quantity":5,"unitPrice":80}]
-- 全部削除
   rows=0 total=0 arrayErr=明細は1行以上必要です

保存ボタンは <form [formRoot]="f"> で受けます。 最初に (submit) で受けていたときは、空の品名のまま保存しても、エラーが表示されませんでした。[formField] が入力欄に required 属性を付けるので、ブラウザー自身の入力チェックが送信を止め、submit イベントが届かなかったためです。FormRoot は form に novalidate を付け、送信時に submit() を呼び、submit() は操作できる欄を touched にします(@angular/forms の型定義と angular.dev の説明による)。FormRoot に変えると、保存で「品名は必須です」が出ました。

リアクティブフォームとの対応

やりたいこと FormArray(リアクティブ) Signal Forms
行を追加 lines.push(createLine()) model.update(…[...lines, newLine()])
行を削除 lines.removeAt(i) model.update(…lines.filter(…))
全行に同じチェック 行を作る関数で Validators を付ける applyEach(p.lines, …)
行数のチェック Validators.required validate(p.lines, …)
重複チェック FormArray のバリデーター 欄の validate で valueOf(p.lines) を見る
合計 valueChanges → toSignal → computed computed で model() を読む
サーバーのデータを入れる clear → push で作り直す model.set(data)
並び替え removeAt → insert モデルの配列を並べ替えて set

Signal Forms は、モデル(ただの配列)を書き換えると、行の部品が自動で増減します。 patchValue で行数が合わない問題は起きません。angular.dev によると、オブジェクトの入った配列の要素には追跡用の印が付き、並び替えても各行の状態(touched・エラー)が行と一緒に動きます。そのため、配列の要素は凍結(Object.freeze)していない普通のオブジェクトにします。

選ぶ目安 向いている方
既存のリアクティブフォームに明細を足す FormArray
valueChanges など RxJS でつないでいる処理が多い FormArray
新しく作るフォームで、値をモデルの配列で持ちたい Signal Forms
サーバーのデータで行数がよく変わる Signal Forms(set だけで済む)

Signal Forms の API 全体は、関連記事「Angular Signal Forms入門|v22安定版のAPI早見表と完成コード」で扱っています。

よくある質問

Q. formArrayName を使わずに書けますか。 A. 行の FormGroup を、行の要素に直接 [formGroup]="line" で渡す書き方もあります。行を子コンポーネントに分けるときは、こちらの形になります(次の質問)。

Q. 行を無効(disabled)にしたら、保存の値から消えました。 A. value は無効な部品を抜いた値です。無効な行も含めるときは getRawValue() を使います。完成コードの保存も getRawValue() にしています。

Q. 行ごとに別のコンポーネントにしたい。 A. 子コンポーネントに input.required<LineForm>() で行の FormGroup を渡し、子のテンプレートで [formGroup]="line()" を使います。削除は output() で親に伝え、親が removeAt します。input・output は関連記事「Angular input・output・modelの使い方|親子の受け渡し」で扱っています。

Q. 文字列の配列(タグなど)だけなら、FormGroup は要りますか。 A. 要りません。fb.array<FormControl<string>>([…]) で、FormControl をそのまま並べます。テンプレートでは [formControlName]="i" を使います。

Q. ngModel(テンプレート駆動フォーム)で同じことはできますか。 A. @for で配列を回して [(ngModel)] を付ける形になり、form の中では name 属性を行ごとに別の値にします。行の増減とまとめてのチェックが多い画面は、FormArray か Signal Forms の方が書きやすくなります。テンプレート駆動フォームは関連記事「Angularテンプレート駆動フォーム入門|ngModelとエラー表示」で扱っています。

まとめ

  • FormArray は、数が変わる入力の並び。1行を FormGroup にして、push・removeAt・insert で増減する
  • 型は FormArray<FormGroup<{…}>>。NonNullableFormBuilder で作ると reset() で null に戻らない。ただし数字の欄を空にすると null になる
  • 1行以上は Validators.required。minLength(1) は空の配列をエラーにしない
  • 重複など行どうしのチェックは FormArray のバリデーターに書き、エラーに行番号を入れて表示する
  • patchValue は行数を変えない。サーバーのデータは clear → push で作り直す
  • 並び替えで moveItemInArray(lines.controls, …) を使うなら、続けて updateValueAndValidity()
  • Signal Forms では、モデルの配列を書き換えるだけで行が増減し、applyEach で全行にチェックを付ける

参考・出典

確認日はいずれも 2026年10月13日です。

  • Angular「FormArray」:https://angular.dev/api/forms/FormArray
  • Angular「Typed forms」:https://angular.dev/guide/forms/typed-forms
  • Angular「Reactive forms」:https://angular.dev/guide/forms/reactive-forms
  • Angular「Validators」:https://angular.dev/api/forms/Validators
  • Angular「Signal Forms: Form models」:https://angular.dev/guide/forms/signals/models
  • Angular「Signal Forms: Schemas」:https://angular.dev/guide/forms/signals/schemas
  • Angular「Signal Forms: Form submission」:https://angular.dev/guide/forms/signals/form-submission
  • Angular「Drag and drop」:https://angular.dev/guide/drag-drop

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

(記載例)出典:株式会社RJC「Angular FormArrayの使い方|行の追加・削除と入力チェックの完成コード」

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