Skip to content

Reactive value signal for compatForm that unwraps AbstractControl values #68097

@d-koppenhagen

Description

@d-koppenhagen

Which @angular/* package(s) are relevant/related to the feature request?

forms

Description

When using compatForm, there is currently no reactive (signal-based) way to access the fully unwrapped current value of the form.

Current behavior

Calling .value() on a FieldTree created via compatForm returns the raw model value. For fields backed by Reactive Forms AbstractControl instances (e.g. FormControl, FormGroup), this returns the control instance itself rather than its current value.

Minimal reproduction

import { Component, computed, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormField, required } from '@angular/forms/signals';
import { compatForm, extractValue } from '@angular/forms/signals/compat';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-root',
  imports: [FormField, ReactiveFormsModule, JsonPipe],
  template: `
    <form>
      <label>Username
        <input type="text" [formField]="myForm.username" />
      </label>

      <fieldset [formGroup]="nameForm">
        <label>Firstname
          <input type="text" formControlName="firstname" />
        </label>
      </fieldset>
    </form>

    <h2>Value:</h2>

    <!-- ❌ Crashes: "Converting circular structure to JSON" -->
    <!-- <pre>{{ myForm().value() | json }}</pre> -->

    <!-- ✅ Works for plain signal fields -->
    <pre>{{ myForm().value().username }}</pre>

    <!-- ⚠️ Have to drill into .value manually for compat fields -->
    <pre>{{ myForm().value().name.value.firstname }}</pre>

    <!-- ✅ extractValue unwraps correctly, but is imperative -->
    <pre>{{ valueJson() | json }}</pre>
  `,
})
export class App {
  protected nameForm = new FormGroup({
    firstname: new FormControl('', Validators.required),
  });

  protected readonly myForm = compatForm(
    signal({
      username: '',
      name: this.nameForm,
    }),
    (path) => {
      required(path.username);
    },
  );

  protected readonly valueJson = computed(() => {
    return extractValue(this.myForm().fieldTree);
  });
}

There are three problems visible here:

  1. myForm().value() returns the FormGroup instance for compat fields. Piping the whole value through | json crashes with "Converting circular structure to JSON" because the FormGroup object contains circular references.
  2. To access the actual value of a compat field, you have to manually drill into the control: myForm().value().name.value.firstname — this defeats the purpose of a unified form value.
  3. extractValue() correctly unwraps AbstractControl values, but it is an imperative function call, not a signal.

Why wrapping extractValue in computed is not sufficient

To use extractValue reactively, you can wrap it in a computed:

protected readonly valueJson = computed(() => {
  return extractValue(this.form());
});

This works partially, but has a significant limitation: extractValue reads .value() internally, which means the computed tracks the signal. But for AbstractControl-backed fields, the .value() signal returns the control instance (a stable reference that never changes), so the computed does not re-evaluate when the underlying control's value changes. The reactive chain is broken for compat fields.

Proposed solution

There should be a reactive, signal-based way to get the fully unwrapped value of a compatForm — a signal or computed that:

  1. Recursively resolves AbstractControl instances to their current values (like extractValue does).
  2. Automatically updates when any field in the form tree changes, including fields backed by Reactive Forms controls.

Possible API ideas (non-prescriptive):

// Option A: A dedicated signal/computed on the FieldTree
const unwrapped = myForm().rawValue(); // Signal<RawValue<TModel>>

// Option B: A reactive version of extractValue
const unwrapped = extractValue.signal(myForm); // Signal<RawValue<TModel>>

// Option C: Make .value() itself unwrap AbstractControls in compat mode
const unwrapped = myForm().value(); // already unwrapped

Alternatives considered

Use case

When incrementally migrating from Reactive Forms to Signal Forms via compatForm, a common need is to reactively consume the entire form value — for example to send it to an API on submit, derive computed state from it, or display it in the template.

A reactive extractValue-equivalent would make compatForm a much smoother migration path and bring it closer to the DX of pure Signal Forms, where .value() just works.

Proposed solution

Provide a signal-based API (e.g. a rawValue signal on FieldTree, or a reactive variant of extractValue) that:

  • Recursively unwraps AbstractControl values in the tree.
  • Participates in Angular's signal graph so that consumers (templates, computed, effect) are notified when any underlying value changes — including values managed by Reactive Forms controls.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    No status

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions