# Reactive forms

> ngwr ships no `ControlValueAccessor`, and never will. `formControlName`, `[formControl]` and `[(ngModel)]` reach an ngwr control through the bridge Angular 22 added for signal-forms controls — which is not an accessor and does not behave like one. This page is what that bridge carries, what it drops, and what your form has to do about each.

Source: https://ngwr.dev/guides/forms  
Kind: Core

## What the bridge is

Two contracts, three ways to bind them, and one thing worth knowing about which half of that is stable.

Nineteen ngwr controls implement `FormValueControl<T>` or `FormCheckboxControl` from `@angular/forms/signals`. Both contracts are Angular public API, tagged `@publicApi 22.0`. A control that implements one exposes its value as a `model()` — `value`, or `checked` for the two boolean controls — plus a `touch` output, a `disabled` input and a `readonly` input. That is the whole surface, and everything below follows from how small it is.

```angular-html
<!-- Signal forms — the native path. [formField] binds the value model directly. -->
<wr-select [formField]="f.plan">…</wr-select>

<!-- Reactive forms — the same model, reached through the bridge. -->
<form [formGroup]="form">
  <wr-select formControlName="plan">…</wr-select>
</form>

<!-- Template-driven — the same bridge again. -->
<wr-select [(ngModel)]="plan" name="plan">…</wr-select>
```

**Signal forms bind that contract directly.**`[formField]` is the native path: it writes the field's value into the `value` model and reads edits back out, and it carries the schema's `disabled()`, `readonly()` and validation state with it.

**Reactive and template-driven forms reach the same model through a bridge inside `NgControl`** — `ngControlCreate` and `ngControlUpdate`, driven by a template instruction that Angular emits for any element carrying a control directive. It runs in three directions. _View to model:_ the control's `value` model emits, and the bridge calls `control.setValue()` and `markAsDirty()`. _Model to view:_ during the host template's change-detection pass, the bridge writes `control.value` back onto the control's model input. _Touched:_ the control's `touch` output becomes `markAsTouched()`.

The contracts are stable. The bridge is an implementation detail.

FormValueControl and FormCheckboxControl are versioned public API. The bridge members on NgControl are protected and carry no stability tag — they are Angular's business, and their behaviour can move on an Angular minor with no ngwr release involved. If your app leans on any of the timings below, pin them with your own specs and re-run those when you bump Angular, not only when you bump ngwr.

## What the bridge carries, and what it drops

The bridge offers each FormControl state to the control by writing an input of that name. It lands only where the control declares one — and an ngwr control declares exactly two, `disabled` and `readonly`. Everything else in this table is silently dropped, which is correct rather than unfinished: the states it drops are the ones `<wr-form-field>` renders.

| FormControl state | Reaches the control? | What that means for you |
| --- | --- | --- |
| `value` | Yes, both ways | The control’s `value` (or `checked`) model **is** the form value. The two sections below are the cases where the model-to-view half does not run. |
| `disabled` | Yes | `disable()` / `enable()` land on the `disabled` input every ngwr control declares. The control greys out and leaves the tab order. |
| `touched` | Reported, not received | Each control emits a `touch` output when focus leaves it, and the bridge turns that into `markAsTouched()`. Nothing comes back down, so a control cannot style itself from `touched` — `<wr-form-field>` reads it off the projected `NgControl` instead. |
| `dirty` | Set, not received | The bridge calls `markAsDirty()` on every edit the control makes. Nothing comes back down. |
| `errors, valid, invalid, pending` | No | Error presentation is `<wr-form-field>`’s job: it reads the projected `NgControl` and owns the message, the `wr-form-field--invalid` class and the `aria-invalid` / `aria-describedby` pair. |
| `required` | No | `Validators.required` puts neither `required` nor `aria-required` on the control. `<wr-form-field required>` is the `*` beside the label and nothing more — you write the validator and the marker separately. |
| `readonly` | Not a reactive-forms state at all | Reactive forms have no read-only concept; only signal forms’ `readonly()` does. Bind `[readonly]` in the template. |

**The one to act on is `required`.** A `Validators.required` on the `FormControl` reaches the control as nothing at all: no `required` attribute, no `aria-required`. Marking the field is a separate, manual step — `<wr-form-field required>` draws the asterisk beside the label. Wire both, or a screen reader is told the field is optional while your validator refuses to submit it.

## `updateOn: 'blur'` and `'submit'` do not apply

This is the difference that surprises a team migrating an existing form, because a native `<input>` in the same FormGroup keeps honouring the option.

A `ControlValueAccessor` stages each edit as a pending value and commits it when `updateOn` says so. The bridge has no pending stage: the moment an ngwr control's model emits, it calls `setValue()` and `markAsDirty()`. So **the value commits, and every validator on it runs, on each keystroke or selection** — whatever `updateOn` was set to.

```typescript
// Angular honours updateOn for a native <input>. An ngwr control ignores it:
// the bridge calls control.setValue() the moment the control's own model
// changes, so the value commits — and every validator runs — on each keystroke
// or selection. A native <input wrInput> in the same group still defers.
const form = new FormGroup({
  email: new FormControl('', { updateOn: 'blur', validators: [Validators.email] }),
});

// What does still happen on blur: touched. Every ngwr control emits a `touch`
// output when focus leaves, and the bridge turns it into markAsTouched().
```

Two consequences worth planning for. **An expensive validator now runs per keystroke**; if the cost was what `updateOn: 'blur'` was buying you, move the work into an async validator and debounce it yourself. And **messages appear on the first edit**: `<wr-form-field>` reveals an error once the control is touched _or_ dirty, and the bridge makes it dirty immediately. There is no input that defers that, and no workaround that is only half a workaround — hiding the projected `<wr-form-error>` behind an `@if` hides the copy, while the field still carries `wr-form-field--invalid` and the control still announces `aria-invalid`, because both are keyed on the errors rather than on the message. Plan for the message arriving on keystroke one.

What still lands on blur is **`touched`**. That half never went through `updateOn`: each control emits `touch` when the user is finished with it — a blur, or the commit that closes an overlay — and the bridge marks the control touched. So a "touched" gate behaves exactly as it always did.

## A silent write does not reach the screen

`setValue` / `patchValue` / `reset` with `{ emitEvent: false }` moves the model and leaves the control showing the old value. In a zoneless app that covers every HTTP callback, timer and websocket message — and the fix is one call.

The model-to-view half of the bridge runs inside the host template's change-detection pass. The only thing that asks for that pass is a `markForCheck()` the bridge subscribes to the control's `valueChanges` and `statusChanges` — and `{ emitEvent: false }` suppresses both. Nothing schedules a pass, so nothing repaints. A native `<input wrInput>` in the same form is unaffected: it is a real accessor, and `writeValue` touches the DOM directly.

**Call `markForCheck()` after a silent write.** That is the whole fix — one call on the host component, covering every control in its template.

```angular-ts
import { ChangeDetectorRef, Component, inject } from '@angular/core';

@Component({ /* … */ })
export class ProfileEditPage {
  private readonly cdr = inject(ChangeDetectorRef);

  load(id: string): void {
    this.profiles.get(id).subscribe(profile => {
      // A silent write moves the model without emitting on valueChanges or
      // statusChanges — the two streams the bridge subscribes to. Nothing asks
      // for a change-detection pass, so the fields keep the old value on screen.
      this.form.patchValue(profile, { emitEvent: false });

      // This is the whole fix. It schedules the pass that re-runs the
      // model-to-view half of the bridge for every control in this template.
      this.cdr.markForCheck();
    });
  }
}
```

```typescript
// The other answer is to not silence the write. The default emits, the
// bridge's own subscription marks the view for check, and a zoneless app
// schedules the pass on its own. Reach for { emitEvent: false } when a
// valueChanges listener would otherwise loop — not as a habit.
this.form.patchValue(profile);
```

Writing from a click handler hides this.

A click has already marked the view dirty, so a silent write made from a template event handler repaints anyway. That is why a “fill the form from the server” bug reproduces in the app and not in the button you added to test it — reproduce it from a setTimeout or a real response.

## An out-of-range value is displayed, not rewritten

Bounds like `[min]`, `[max]` and `[count]` govern what a control's own edits may produce. A value written in from a FormControl is drawn as best the control can and left in the model exactly as it stands.

The rule is one decision applied five times, and the reasoning is worth having: a control that clamps on write **erases the error the validator exists to report** — the out-of-range number a `Validators.max` would have flagged is gone before anything reads it — and the write **marks a pristine form dirty on first paint**, which trips unsaved-changes guards and opens every other message on the page. So the model stays yours until the user touches the control.

| Control | A value outside its bounds | null |
| --- | --- | --- |
| `wr-input-number` | Shown as written. `[min]` / `[max]` bound what typing and the steppers produce; a value written in is displayed as it stands. | Empty field. Clearing the field writes `null` back, never `0`. |
| `wr-slider` | The thumb clamps to `[min, max]` — it cannot render off its own track — and the model keeps the number it was given. | `WrSliderValue` is `number \| [number, number]`, so a non-number is ignored and the thumb stays put. Reset to a number, not to `null`. |
| `wr-rating` | Drawn and announced clamped to `[0, count]`; the model keeps the number. | No stars filled. |
| `wr-select` | A value no option matches shows the placeholder — see the next section. In search mode the trigger falls back to `displayWith(value)` instead. | Placeholder. |
| `wr-date-picker` | Text the adapter cannot parse leaves the committed date alone rather than guessing at one. | Empty field. |

The consequence to design around: **a field can look wrong while the form is perfectly consistent.** If a control shows something other than what you set, read `control.value` before suspecting the control — it is almost always holding exactly what you gave it.

## Object values in `\<wr-select>`

Identity is `===`, there is no `compareWith`, and this is the one gap in the list that fails silently in both directions at once.

`<wr-select>` finds the selected option by comparing the bound value with each `<wr-option [value]>` using `===`. There is no `compareWith` input and no identity key. An object that came back from a second request is structurally equal to the one in your options list and is not the same reference, so **no option matches, the trigger shows the placeholder, and the form value is unchanged and valid** — a field that looks empty on a form that will submit happily.

```typescript
// <wr-select> matches the bound value against each option with ===, and there
// is no compareWith input. A structurally equal object from a second request is
// a different reference, so nothing matches and the trigger falls back to the
// placeholder — while the form value stays exactly what you set, and valid.

// Don't bind the object:
const category = new FormControl<Category | null>(null);
category.setValue({ id: 2, name: 'Laptops' }); // never matches <wr-option [value]="cat">

// Bind the key, and resolve the object where you need the rest of it:
const categoryId = new FormControl<number | null>(null);
categoryId.setValue(2);
```

```angular-html
<wr-select formControlName="categoryId" placeholder="Pick a category">
  @for (cat of categories(); track cat.id) {
    <wr-option [value]="cat.id">{{ cat.name }}</wr-option>
  }
</wr-select>
```

Bind a primitive key and resolve the object where you need it. If the value genuinely has to be the object, keep one canonical instance per id in the store the options render from, so the reference the form holds is the reference the option carries.

## FormArray, nested groups and child components

Nothing special is required, because nothing about the bridge is structural: it works per control, and does not care how the directive found its FormControl.

`formArrayName`, `formGroupName`, a `[formControl]` handed to a presentational child, and a control resolved through `ControlContainer` in a child component all bind ngwr controls the same way they bind native ones.

```angular-html
<!-- FormArray, formGroupName, and a control resolved through ControlContainer
     inside a child component all work: the bridge is per-control and does not
     care how the directive found its FormControl. -->
<form [formGroup]="form">
  <div formArrayName="lines">
    @for (line of lines.controls; track line) {
      <div [formGroupName]="$index">
        <wr-input-number formControlName="qty" />
        <wr-select formControlName="sku">…</wr-select>
      </div>
    }
  </div>
</form>
```

Track the group, not the index.

An @for over a FormArray tracked by $index does not re-bind [formGroupName] after removeAt(), so the row left behind keeps rendering the removed group's values. This is Angular's behaviour and it hits a native \<input> identically — but it looks like a control bug, so it is worth naming here.

One thing to own yourself: **the bridge's timings are not covered by ngwr's test suite per component.** Angular tests the synthesis; ngwr's specs prove the contracts. If your app depends on any behaviour on this page, write the spec for it — the [testing guide](https://ngwr.dev/guides/testing) covers the harnesses to drive the controls with, and [quality](https://ngwr.dev/start/quality) is honest about where that coverage stops.

## See also

- [WrFormField](https://ngwr.dev/reference/components/form-field) — Label, hint and the error message — the half of form state the bridge does not carry.
- [WrSelect](https://ngwr.dev/reference/components/select) — Value identity is `===`. Its page says what to bind when the options are objects.
- [WrInputNumber](https://ngwr.dev/reference/components/input-number) — Where `[min]` / `[max]` apply, and where they deliberately do not.
- [WrValidators](https://ngwr.dev/reference/validators) — The extra validators, and the error keys `\<wr-form-field>` already has copy for.
- [Testing](https://ngwr.dev/guides/testing) — The CDK harnesses — and where the specs that pin this bridge for your app belong.
