skip to content

In Angular, why does patchValue on an order form with an empty items FormArray drop a saved order's line items, and how do you load them?

level: middleimportance: should knowfreq 46%

answer

  1. values need somewhere to land
  2. patching never creates controls
  3. setValue is strict about shape
  4. clear then push, or setControl

basics

~20 s

patchValue and setValue only write values into controls that already exist, and a FormArray never creates rows from data. Rebuild the array first, with clear() and push() per line or setControl('items', newArray), then patch or set the values.

solid answer

~50 s

A `FormArray`'s `patchValue()` walks the incoming array and writes each element only if a control exists at that index, so with an empty `items` array every saved line is silently skipped. `setValue()` is strict instead: it throws when the data has more elements than the array has controls (on an empty array the message says no controls are registered yet), and it requires a value for every existing control. Neither method creates controls. To load a saved order, reshape the array to the data first: `items.clear()` and then `items.push(this.newItem(line))` for each line, or replace it in one step with `order.setControl('items', this.fb.array(lines.map(...)))`, then patch the remaining fields. Pass `{emitEvent: false}` during the rebuild if subscribers should see only the final state, and remember that `setControl()` replaces the instance, so any code holding the old array must re-read it.

code

ts · 18 lines
ts
import {FormArray, FormControl} from '@angular/forms';

const items = new FormArray<FormControl<string>>([]);

items.patchValue(['A-100', 'B-200']);
console.log(items.length); // 0 - no controls, so nothing was written

try {
  items.setValue(['A-100']);
} catch (e) {
  console.log((e as Error).message); // no form controls registered with this array yet
}

items.clear();
for (const sku of ['A-100', 'B-200']) {
  items.push(new FormControl(sku, {nonNullable: true}));
}
console.log(items.value); // ['A-100', 'B-200']

go deeper

for a junior

Recall that patchValue and setValue only write into controls that already exist, so rows must be created before loading data.

for a middle

Explain patchValue's skip-missing behaviour versus setValue's strict errors, and rebuild the array with clear and push or with setControl.

for a senior

Choose between keeping and replacing the array instance based on who holds references or subscriptions, and silence events during the rebuild.

for a principal

Standardise one load path per form, shared with discard-changes and create flows, so structure and values can never be restored out of step.

## The symptom An edit screen in an Angular app fetches a saved order and calls `this.order.patchValue(savedOrder)`. The customer name appears, but the line-item section shows the single empty row the form was created with, or none at all. No error is logged. ## Why it happens Reactive forms separate **structure** (which controls exist) from **values** (what they hold). The value-writing methods only ever touch the second: | Method on a `FormArray` | Data longer than the array | Data shorter than the array | |---|---|---| | `patchValue(data)` | Extra elements silently ignored | Remaining controls left as they are | | `setValue(data)` | Throws `Cannot find form control at index: n` (or a no-controls error on an empty array) | Throws `Must supply a value for form control at index: n` | | `reset(data)` | Does not add controls | Remaining controls reset | `patchValue()` loops over the incoming array and writes an element only if `at(index)` returns a control. With an empty `items` array, nothing matches, and the whole list is dropped. With one row, the first saved line lands there and the rest vanish. `setValue()` makes the mismatch loud, which is useful in development, but it does not fix it: neither method ever **creates** a control. ## Fix 1: rebuild the rows, then write values The row factory from the create screen can accept initial data: ```ts newItem(line: {sku: string; quantity: number} = {sku: '', quantity: 1}) { return this.fb.group({ sku: [line.sku, Validators.required], quantity: [line.quantity, [Validators.required, Validators.min(1)]], }); } load(saved: {customer: string; items: {sku: string; quantity: number}[]}) { this.items.clear({emitEvent: false}); for (const line of saved.items) { this.items.push(this.newItem(line), {emitEvent: false}); } this.order.patchValue({customer: saved.customer}); } ``` `clear()` removes the placeholder rows in one step, each `push()` adds a row that already holds its data, and the final `patchValue()` for the fixed fields runs validation and emits once for the whole form. ## Fix 2: replace the array in one call ```ts this.order.setControl('items', this.fb.array(saved.items.map((l) => this.newItem(l)))); this.order.patchValue({customer: saved.customer}); ``` `setControl()` swaps in a new `FormArray` instance and notifies the bound form directive, which reconnects the row directives to the new controls. It is concise, but it has one consequence to plan for: - Code that stored the **old** array, such as a `readonly items = this.order.controls.items` field or a subscription to `items.valueChanges`, still points at the discarded instance. A getter that reads `this.order.controls.items` each time is immune. ## Choosing between them 1. Keep the same instance (`clear()` plus `push()`) when other code subscribes to the array or holds a reference to it. 2. Replace the instance (`setControl()`) when the form is freshly created for this screen and only the template reads it. 3. Either way, build rows through the same factory the create flow uses, so validators and defaults stay identical. ## Guarding the gap before data arrives The saved order usually arrives asynchronously. Until it does, the user sees the empty form and could start typing into it, only for `load()` to overwrite their input. Two simple guards: - Create the form disabled with `order.disable()` and call `order.enable()` at the end of `load()`. - Or render the form only once the data is present, with an `@if` around it and a loading message in `@else`. Either way, `load()` runs once per fetched order, and if the component is reused for another order, it runs again against the same form, which is exactly why it must `clear()` before pushing. ## Related traps - **Resetting a loaded form.** `reset()` on the array sets the existing rows back to their defaults but does not restore the saved number of rows. If "Discard changes" should restore the saved order, run the same `load()` again. - **Dirty state.** Rebuilding rows is a structural change and leaves the form pristine, which is usually what an edit screen wants right after loading. - **Nested arrays.** The same rule applies at every level: an order line with a nested `FormArray` of options needs those rows built too, before values can land in them. The principle to state in an interview: **values follow structure**. Build the controls the data needs, then write the data.

  • After loading with setControl('items', newArray), a valueChanges subscription on items stops firing. Why?
    The subscription was made on the old `FormArray` instance, and `setControl()` replaced that instance. The old array no longer belongs to the form, so it never emits again. Subscribe after loading, subscribe to the parent's `valueChanges`, or load with `clear()` and `push()` so the instance stays the same.
  • Why would you pass {emitEvent: false} to clear() and push() during the load?
    Each call otherwise emits `valueChanges` and `statusChanges`, so listeners such as an autosave or a total calculator would run once per row with half-built data. Silencing the rebuild and finishing with one normal `patchValue()` or `updateValueAndValidity()` gives subscribers a single, complete state.

saying these in an interview costs you the question

  • patchValue adds FormArray rows when the data has more items.
  • setValue grows the array to match the data.
  • Assigning order.controls.items = newArray is the same as setControl.
  • reset(savedValue) restores the saved number of rows.
  • patchValue throws when the array is too short.