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.
<!-- 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.
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.
// 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.
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();
});
}
}// 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.
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.
// <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);<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.
<!-- 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.
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 covers the harnesses to drive the controls with, and quality is honest about where that coverage stops.
See also
- ComponentWrFormFieldLabel, hint and the error message — the half of form state the bridge does not carry.
- ComponentWrSelectValue identity is `===`. Its page says what to bind when the options are objects.
- ComponentWrInputNumberWhere `[min]` / `[max]` apply, and where they deliberately do not.
- ValidatorWrValidatorsThe extra validators, and the error keys `<wr-form-field>` already has copy for.
- GuideTestingThe CDK harnesses — and where the specs that pin this bridge for your app belong.