- Angular
Angular FormArrayの使い方|行の追加・削除と入力チェックの完成コード
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期連続の黒字決算。