Marand wordmark Marand Icon
Sl | En

Fun-grained reactivity in Angular: Part 2 – Forms

author picture

Miha Mulec

· · 17 min read
Zonless ready forms

It's been a while since Part 1 - Primitives, where we played around with some basic signal building blocks like mutable, store, and resource. Life happens, Angular updates happen, and now we've got signal.model... but some problems just stick around. And in the world of forms, that problem is, well, forms.

Angular 17 gave us signal-based ngModel, which was supposed to be the answer to all our two-way binding woes. And it sort of works... until you start dealing with objects or arrays. Then things get a little weird.

See, ngModel, bound to a property of an object within a signal, has this habit of mutating your data inline. Like this:

@Component({
  selector: "app-root",
  imports: [FormsModule],
  template: `
    <input [(ngModel)]="user().name" />
    <p>Hello, {{ user().name }}!</p>
    <p>Hello, {{ userName() }}</p>
    <button (click)="force()">Force change detection</button>
  `,
})
export class AppComponent {
  protected readonly user = signal({ name: "Initial Name" });
  protected readonly userName = computed(() => this.user().name);

  protected force() {
    this.user.update((cur) => ({ ...cur }));
  }
}

You type in the input, and user().name changes. Great! Except... the user signal itself didn't technically update. The object inside it did. Which means any computed or effect watching user might not notice a thing. You've successfully mutated your data under the radar, and your carefully crafted reactive graph is now potentially out of sync. Not ideal. Even more weird, the angular renderer sometimes detects these changes "correctly" (when using zone.js change detection). When using zoneless, or by using a computed like "userName" above the changes arent detected until the user object is destructured, at which point data will "snap" to the value actually set within the input.

Therein lies the a quick fix to this problem, triggering the change detection on ngModelChange emission, by either directly updating the value or using a generic trigger function:

// Option 1: Destructure on update
<input [ngModel]="user().name" (ngModelChange)="updateName($event)" />

updateName(newName: string) {
  this.user.update(currentUser => ({ ...currentUser, name: newName }));
}
// Option 2: If you have multiple props, this might be easier
<input [(ngModel)]="user().name" (ngModelChange)="triggerChange()" />

triggerChange() {
   this.user.update((cur) => ({...cur}));
}

If all you're looking for here is a quick solution to this problem, there you go...but I think we can do better...much better ;). So, in this article we're going to ditch workarounds like this and build our own reactive form primitives, replacing ngModel almost fully. Our goals with this re-work:

  • Predictability: No undetectable mutations
  • Render performance: Only update the parts of the dom that need to update
  • Type safety: This isn't an issue with ngModel, but rather the ControlValueAccessor interface, which doesn't verify the types.
  • Extensibility: Allow relatively easy enchancements
  • & most importantly...full reactivity: Changes to properties cause signal updates, so that dependent computeds/effects fire as expected

Core primitives

Before we begin working on the form control signal & its derivatives, we need to quickly discuss a few core concepts & create some primitives that our solution will use. The first thing I'd like you to take a look at is nested computeds, I think this was not allowed in angular 17, but I could be wrong there..it definitely works with ng18+.

Here's a bit of an upcoming part (tables) which uses this, it's a very simplified/contrived example, but it shows where we can use this. Specifically when working with paginated data we can render the rows (and calculate everything related to them) once..or rather only if the "length" of the page changes. The cells however need to be dynamic as data on another page will be different. Doing it like this causes minimal re-renders. Don't worry we'll show the full data table code when thats finished in an upcoming article, look forward to it! :)

function equalsUser(a: User, b: User) {
  return a.id === b.id;
}

function accessName(u: User) {
  return u.name;
}

const data = signal([
  { id: 1, name: "John" },
  { id: 2, name: "Peter" },
]);

const length = computed(() => data().length); // stabilize so that rows are only re-rendered if the length of the paginated data changes

const rows = computed(() =>
  Array.from({ length: length() }).map((_, i) => {
    const value = computed(() => data()[i], { equal: equalsUser });
    return {
      value,
      cells: [createCell(value, accessName)], // many columns but in this contrived example only one
    };
  })
); // create row signals

function createCell(data: Signal<User>, accessor: (value: T) => string) {
  const;
  return computed(() => accessor(data())); // change cell when data changes
}

The key takeaway here is that the rows computed creates an array of objects, each containing a value signal (representing a row) and an array of cells. Each cell is its own computed. This means that if we update the name of a single user in the data signal, only the corresponding cell computed for that user's name will re-evaluate. The rows computed might re-evaluate (if the page size changes), but thanks to the nested structure, we avoid unnecessary calculations/re-renders.

Two-Way Binding with Signals: The derived Primitive

Alright knowing that, we need to create a system where data flows in both directions (two way binding) in a stable way. Angular has one requirement here, to allow two-way binding it expects a WritableSignal, which it reads and calls .set on.

We could create a signal with internal signals, like so signal({name: signal('John')}), that will allow us to change the name, sure, but it completely breaks the reactive graph because now the name signal is not aware it depends on the user signal, ideally we'd simply derive the value from the user signal. Considering that, we could try the new linkedSignal primitive for this like so const user = signal({name: 'John'}); const name = linkedSignal(() => user().name), this will ensure that changes to the user object flow down to the name signal, but changes to the name signal don't "trigger" the user signal, so even though the value of "user" has changed when we change name, no computeds/effects depending on the user signal will "detect" that change. We could of course use an effect to change the user signal every time name changes, but that would completely break the reactive graph, as angular can't track signal dependencies when setting a value from an effect.

To solve these conundrums, we need to create our own primitives, something which reacts to changes in the parent signal, but also triggers the parent when it itself changes. In essence, what we really want is a computed, which has a set function that applies data to the parent. On that change the value part of the computed is re-calculated, which in turn updates the DOM. Here are two primitives that make this easier:

import { computed, untracked, type CreateSignalOptions, type Signal, type WritableSignal } from "@angular/core";

// Makes a computed writable.
export function toWritable<T>(signal: Signal<T>, set: (value: T) => void, update?: (updater: (value: T) => T) => void): WritableSignal<T> {
  const internal = signal as WritableSignal<T>;
  internal.asReadonly = () => signal; // just returns the value, as it's already a computed
  internal.set = set; // "fakes" the setter function of a WritableSignal
  internal.update = update ?? ((updater) => set(updater(untracked(internal)))); // "fakes" the update function of a WritableSignal

  return internal;
}

type CreateDerivedOptions<T, U> = CreateSignalOptions<U> & {
  from: (v: T) => U;
  onChange: (newValue: U) => void;
};

export type DerivedSignal<T, U> = WritableSignal<U> & {
  from: (v: T) => U;
};

// Creates a signal for a parent-child relationship
export function derived<T, U>(source: WritableSignal<T>, { from, onChange, ...rest }: CreateDerivedOptions<T, U>): DerivedSignal<T, U> {
  const sig = toWritable<U>(
    computed(() => from(source()), rest),
    (newVal) => {
      onChange(newVal);
    }
  ) as DerivedSignal<T, U>;

  sig.from = from;

  return sig;
}

export function isDerivedSignal<T, U>(sig: WritableSignal<U>): sig is DerivedSignal<T, U> {
  return "from" in sig;
}

export function toFakeDerived<T, U>(initial: WritableSignal<U>): DerivedSignal<T, U> {
  const sig = initial as DerivedSignal<T, U>;
  sig.from = () => untracked(initial);
  return sig;
}

These two primitives allow us to perfectly describe the relationship between user and name, thus creating two WritableSignals, which remain in perfect sync like so:

const user = signal({name: 'John', age: 30});
// only fires when name changes, but checks every time user changes. Triggers user on changes
const name = derived(user, {
    from: (u) => u.name,
    onChange: (next) => user.update((cur) => ({...cur, name: next}));
});
// only fires when age changes, but checks every time user changes. Triggers user on changes
const age = derived(user, {
    from: (u) => u.age,
    onChange: (next) => user.update((cur) => ({...cur, age: next}));
});

Since we're leaving "other" properties alone..even if those are sub-objects, the equality check of each derived signals internal computed wont re-fire if something else changes. If we want to stabilize it even more, we can provide our own equality function. Alright, I think we're now ready for forms :)

Form Control (finally!)

Lets start by looking at the type we're outlining here, I've added quite a few features here, keep/remove them as necessary within your codebase. Please note that while we're creating a lot of computeds here, the actual functions that calculate their values are never triggered until that computed (or its parent) is subscribed to at least once. So we can almost freely create as many as we want, unused things simply wont affect performance in any meaningful way.

import { type Signal, type WritableSignal } from "@angular/core";
import { type DerivedSignal } from "./primitives";
import { v7 } from "uuid";

export const CONTROL_SYMBOL = Symbol.for("INTERNAL_CLIENT_FORM_CONTROL");

export type ControlType = "control" | "array" | "group";

export type FormControlSignal<T, TParent = undefined, TControlType extends ControlType = "control", TPartialValue = T | undefined> = {
  id: string; // unique identifier meant for tracking in @for/ngFor loops
  value: WritableSignal<T>; // the main value signal
  dirty: Signal<boolean>; // True when the value has been changed, false if reverted back
  touched: Signal<boolean>; // Triggered when the control has been interacted with, for example blur on an input
  error: Signal<string>; // Error message to be displayed
  disabled: Signal<boolean>; // True when the control is disabled
  readonly: Signal<boolean>; // True when the control is readonly
  required: Signal<boolean>; // True when the control is required, used to add * to the label
  label: Signal<string>; // Label to be displayed
  hint: Signal<string>; // Hint to be displayed
  markAsTouched: () => void; // Marks the control as touched
  markAllAsTouched: () => void; // Marks the control and child controls as touched
  markAsPristine: () => void; // Sets touched to false
  markAllAsPristine: () => void; // Sets touched to false on self and all child controls
  reconcile: (newValue: T) => void; // Resets the control to a new value & new initial value, meant for new data comming from the server, only sets if the value is not dirty. This retains changes the user has made to the form
  forceReconcile: (newValue: T) => void; // Same as reconcile, but also resets if the value is dirty
  reset: () => void; // Resets the field to the initial value
  resetWithInitial: (initial?: T) => void; // Resets the field to the initial value & sets initial if provided
  from?: DerivedSignal<TParent, T>["from"]; // Used in form groups & arrays to reconcile the data
  equal: (a: T, b: T) => boolean; // Provided equality function, defaults to Object.is
  [CONTROL_SYMBOL]: true; // Symbol for internal type checking
  controlType: TControlType; // Control type for type checking & reconciliation
  partialValue: Signal<TPartialValue>; // Value that is used when "patching" the state on the server, is a deep partial of the value
};

As you can see we've created quite the interface there, I'll leave some things for later such as the "from" property, as that'll be obvious once we get to form groups, but there are a few things id like to ellaborate on:

  1. We're using a primitive id for @for tracking, due to some very weird behavior when using signals in the tracking function, I won't get into it now but @for(...;track obj.value()) simply won't work as expected.
  2. These control signals are relatively expensive to create, but they should only really be constructed "once" in the case of a singular form. In our user signal example, we'd construct only one signal for each property & keep it for the lifetime of the form, what gets changed are sub-signals such as value, dirty, error etc.
  3. Note that while we're creating quite a few computeds angular will not fire the functions within them until they are subscribed to. I explain this in depth in Part 1 - Primitives, so we don't have to worry about how expensive functions are unless we call them. There is an insignificant expense paid to create new computeds, but that is a performance aspect we're not considering here.
  4. Reconciliation is something we require in our system, since we have data that can change during the lifetime of a form through an SSE subscription triggering when another user changes the same entity, I've included it, because it's a cool feature, but feel free to remove it
  5. Partial patches are the same as reconciliation, we use it to optimize the data going upstream over the network, if your server/s dont support it, you can remove it, or simply not call it anywhere (see point 3).
  6. Errors are always strings, validators either return an error message such as "Name is required" or an empty string "", an empty string is considered valid. The system assumes synchronous validation, but can easily be extended to support async validators if you require them, just make the validator return a string while pending and then either an empty string/message depending on if its valid.
export type CreateFormControlOptions<T, TControlType extends ControlType = ControlType> = CreateSignalOptions<T> & {
  validator?: () => (value: T) => string;
  onTouched?: () => void;
  disable?: () => boolean;
  readonly?: () => boolean;
  required?: () => boolean;
  label?: () => string;
  id?: () => string;
  hint?: () => string;
  dirtyEquality?: ValueEqualityFn<T>;
  onReset?: () => void;
  controlType?: TControlType;
  overrideValidation?: () => string;
};

const NO_ERROR_FN = (): never => {
  return null as never;
};

const ERROR_FN = (): never => {
  throw new Error();
};

function runInNonErrorSignalContext(fn: () => void) {
  setThrowInvalidWriteToSignalError(NO_ERROR_FN);
  fn();
  setThrowInvalidWriteToSignalError(ERROR_FN);
}

export function formControl<T, TParent = undefined, TControlType extends ControlType = "control", TPartialValue = T | undefined>(initial: DerivedSignal<TParent, T> | T, opt?: CreateFormControlOptions<T, TControlType>): FormControlSignal<T, TParent, TControlType, TPartialValue> {
  const value = isSignal(initial) ? initial : signal(initial, opt);
  const initialValue = signal(untracked(value));
  const eq = opt?.equal ?? Object.is;

  const dirtyEq = opt?.dirtyEquality ?? eq;

  const disabled = computed(() => opt?.disable?.() ?? false);
  const readonly = computed(() => opt?.readonly?.() ?? false);

  const dirty = computed(() => !dirtyEq(value(), initialValue()));

  const touched = signal(false);

  const validator = computed(() => opt?.validator?.() ?? (() => ""));

  const error = computed(() => {
    if (opt?.overrideValidation) return opt.overrideValidation();
    if (disabled() || readonly()) return "";
    return validator()(value());
  });

  const markAsTouched = () => {
    touched.set(true);
    opt?.onTouched?.();
  };
  const markAllAsTouched = markAsTouched;

  const markAsPristine = () => touched.set(false);
  const markAllAsPristine = markAsPristine;

  const label = computed(() => opt?.label?.() ?? "");

  const patchValue = computed(() => (dirty() ? value() : undefined));

  const internalReconcile = (newValue: T, force = false) => {
    const isDirty = untracked(dirty);

    if (!isDirty || force) {
      runInNonErrorSignalContext(() => {
        initialValue.set(newValue);
        value.set(newValue);
      });
    }
  };

  return {
    id: opt?.id?.() ?? v7(),
    value,
    dirty,
    touched,
    error,
    label,
    required: computed(() => opt?.required?.() ?? false),
    disabled,
    readonly,
    hint: computed(() => opt?.hint?.() ?? ""),
    markAsTouched,
    markAllAsTouched,
    markAsPristine,
    markAllAsPristine,
    from: (isSignal(initial) ? initial.from : undefined) as FormControlSignal<T, TParent, TControlType>["from"],
    reconcile: (newValue: T) => internalReconcile(newValue),
    forceReconcile: (newValue: T) => internalReconcile(newValue, true),
    reset: () => {
      opt?.onReset?.();
      value.set(untracked(initialValue));
    },
    resetWithInitial: (initial: T) => {
      opt?.onReset?.();
      initialValue.set(initial);
      value.set(initial);
    },
    equal: eq,
    [CONTROL_SYMBOL]: true,
    controlType: (opt?.controlType ?? "control") as TControlType,
    partialValue: patchValue as Signal<TPartialValue>,
  };
}

Ok we now have the implementation of the core formControl signal, as you can see it's basically what we specified in the type, with some typecasting required to make TS happy, though this isn't a problem as it's only internal, the function signature guarentees type safety even with the casts. The provided value is either a derivation or a direct value if the formControl is being used standalone.

One thing of note is the runInNonErrorSignalContext function, this is a very dangerous one so don't use it unless you know exactly when/why :). What this does is allows us to set signal values within a reactive context, specifically a computed or linkedSingal. Using this could potentially cause infinite loops or unexpected change detection behavior, but the current implementation is safe in this specific case.

Form Group

Next up we'll look at the form group, this is meant to be used to provide a control layer where we have a parent and controls that bind to child properties of that parent, as such it requires derived signals to work. As you can see bellow the form group is pretty much just a form control, but also adds the children signal property so that we can access child controls & bind them to various inputs. Various properties such as .error, .touched, .markAllAsTouched read/write to itself and its children.

This is quite similar to angulars internal FormGroup's purpose, but due to the ability to provide a reactive child function it also functions as a typesafe FormRecord replacement. Lets take a look, since I think the code will be more-or-less self-explanatory.

import { computed, isSignal, signal, untracked, type Signal, type WritableSignal } from "@angular/core";
import { formControl, type CreateFormControlOptions, type FormControlSignal } from "./form-control";
import { isDerivedSignal, toFakeDerived, type DerivedSignal } from "./primitives";

type SignalValue<T> = T extends Signal<infer U> ? U : never;

type DerivationPartialValues<TDerivations extends Record<string, FormControlSignal<any, any, any, any>>> = {
  [K in keyof TDerivations]: Exclude<SignalValue<TDerivations[K]["partialValue"]>, undefined>;
};

export type FormGroupSignal<T, TDerivations extends Record<string, FormControlSignal<any, T, any, any>>, TParent = undefined> = FormControlSignal<T, TParent, "group", Partial<DerivationPartialValues<TDerivations>>> & {
  children: Signal<TDerivations>; // typesafe signal of child controls
  ownError: Signal<string>; // the error signal holds an INVALID state if the group or its children are invalid, this only returns the validation results of the group, so that they can be displayed
};

type UnknownObject = Record<PropertyKey, unknown>;

function entries<T extends UnknownObject>(obj: T): [keyof T, T[keyof T]][] {
  return Object.entries(obj) as [keyof T, T[keyof T]][];
}

function values<T extends UnknownObject>(obj: T): T[keyof T][] {
  return Object.values(obj) as T[keyof T][];
}

export function formGroup<T, TDerivations extends Record<string, FormControlSignal<any, T, any, any>>, TParent = undefined>(initial: DerivedSignal<TParent, T> | T | WritableSignal<T>, providedChildren: (() => TDerivations) | TDerivations, opt?: CreateFormControlOptions<T>): FormGroupSignal<T, TDerivations, TParent> {
  const valueSignal = isSignal(initial) ? initial : signal(initial);
  // we fake a derivation if not present, so that .from is present on the signal
  const value = isDerivedSignal<TParent, T>(valueSignal) ? valueSignal : toFakeDerived<TParent, T>(valueSignal);

  // we allow for a function/signal to be passed, this case should only be used if the child controls change dependent upon something, say if a formControl is flipped into a formGroup.
  const children = typeof providedChildren === "function" ? computed(() => providedChildren()) : computed(() => providedChildren);

  // array allows for easier handling
  const derivationsArray = computed(() => values(children()));

  // group control
  const ctrl = formControl<T, TParent, "group">(value, {
    ...opt,
    controlType: "group",
    readonly: () => {
      // readonly if is readonly or all children are readonly
      if (opt?.readonly?.()) return true;
      return !!derivationsArray().length && derivationsArray().every((d) => d.readonly());
    },
    disable: () => {
      if (opt?.disable?.()) return true;
      return !!derivationsArray().length && derivationsArray().some((d) => d.disabled());
    },
  }) satisfies FormControlSignal<T, TParent, "group">;

  const childrenTouched = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.touched()));

  const touched = computed(() => ctrl.touched() || childrenTouched());

  const childrenDirty = computed(() => !!derivationsArray().length && derivationsArray().some((d) => d.dirty()));

  const dirty = computed(() => ctrl.dirty() || childrenDirty());

  const childError = computed(() => {
    if (!derivationsArray().length) return "";
    return derivationsArray()
      .map((d) => d.error())
      .join("\n");
  });

  const error = computed(() => {
    const ownError = ctrl.error();
    if (ownError) return ownError;
    return childError() ? "INVALID" : "";
  });

  const markAllAsTouched = () => {
    ctrl.markAllAsTouched();
    for (const ctrl of untracked(derivationsArray)) {
      ctrl.markAllAsTouched();
    }
  };

  const markAllAsPristine = () => {
    ctrl.markAllAsPristine();
    for (const ctrl of untracked(derivationsArray)) {
      ctrl.markAllAsPristine();
    }
  };

  const reconcile = (newValue: T) => {
    // set the children values based on the derivation of the new value
    for (const ctrl of untracked(derivationsArray)) {
      const from = ctrl.from;
      if (!from) continue;
      ctrl.reconcile(from(newValue));
    }
    ctrl.reconcile(newValue);
  };

  const forceReconcile = (newValue: T) => {
    for (const ctrl of untracked(derivationsArray)) {
      const from = ctrl.from;
      if (!from) continue;
      ctrl.forceReconcile(from(newValue));
    }
    ctrl.forceReconcile(newValue);
  };

  const partialValue = computed(() => {
    const obj: Partial<DerivationPartialValues<TDerivations>> = {};
    if (!dirty()) return obj;

    for (const [key, ctrl] of entries(children())) {
      const pv = ctrl.partialValue();
      if (pv === undefined) continue;
      obj[key] = pv;
    }

    return obj;
  });

  return {
    ...ctrl,
    children,
    partialValue,
    reconcile,
    forceReconcile,
    ownError: ctrl.error,
    touched,
    dirty,
    error,
    markAllAsPristine,
    markAllAsTouched,
    reset: () => {
      for (const ctrl of untracked(derivationsArray)) {
        ctrl.reset();
      }
      ctrl.reset();
    },
    resetWithInitial: (initial: T) => {
      for (const ctrl of untracked(derivationsArray)) {
        const from = ctrl.from;
        from ? ctrl.resetWithInitial(from(initial)) : ctrl.reset();
      }
      ctrl.resetWithInitial(initial);
    },
  };

Form Array

While the basic formControl & formGroup should handle most usecases there is an edge case covered by angulars FormArray, this is where we need an array of controls, which we add/remove controls from so that we can add values, which require sub-controls to an array for example. If we're working with just an array of something, where the values don't require child controls, you should just use a basic formControl of type T[]. Within this primitive we're also reconciling the child formControls. This allows us to react to value changes, but save compute by skiping the creation for existing control.

import { computed, linkedSignal, untracked, type Signal, type WritableSignal } from "@angular/core";
import { formControl, type CreateFormControlOptions, type FormControlSignal } from "./form-control";
import { type SignalValue } from "./form-group";
import { derived, type DerivedSignal } from "./primitives";

function minArrayLengthMsg(min: number, elementLabel: string) {
  return `Min ${min} ${elementLabel}`;
}

function maxArrayLengthMsg(max: number, elementLabel: string) {
  return `Max ${max} ${elementLabel}`;
}

function minArrayLengthValidator(min: number, elementLabel: string) {
  const msg = minArrayLengthMsg(min, elementLabel);
  return (val: any[]) => (val.length >= min ? "" : msg);
}

function maxArrayLengthValidator(max: number, elementLabel: string) {
  const msg = maxArrayLengthMsg(max, elementLabel);
  return (val: any[]) => (val.length <= max ? "" : msg);
}

export type ArrayValidatorOpt = {
  min?: number;
  max?: number;
  elementLabel?: string;
};

export function arrayValidator<T extends any[]>(opt: ArrayValidatorOpt = {}) {
  const min = opt.min ?? 0;
  const max = opt.max ?? Number.MAX_SAFE_INTEGER;
  const elementLabel = opt.elementLabel ?? "items";

  const minVal = minArrayLengthValidator(min, elementLabel);
  const maxVal = maxArrayLengthValidator(max, elementLabel);

  return (val: T) => minVal(val) || maxVal(val);
}

export type FormArraySignal<T, TIndividualState extends FormControlSignal<T, any, any, any> = FormControlSignal<T, any, any, any>, TParent = undefined> = FormControlSignal<T[], TParent, "array", Exclude<SignalValue<TIndividualState["partialValue"]>, null | undefined>[] | undefined> & {
  ownError: Signal<string>;
  children: Signal<TIndividualState[]>;
  push: (value: T) => void; // add new control with value
  remove: (index: number) => void; // remove at index
  min: Signal<number>; // for display purposes
  max: Signal<number>; // for display purposes
  canAdd: Signal<boolean>; // disable add button if false
  canRemove: Signal<boolean>; // disable remove buttons if false
};

export type CreateFormArraySignalOptions<T, TIndividualState extends FormControlSignal<T, any, any, any>> = CreateFormControlOptions<T> & {
  min?: () => number;
  max?: () => number;
  elementLabel?: () => string;
  toPartialValue?: (v: T) => Exclude<SignalValue<TIndividualState["partialValue"]>, null | undefined>;
};

function createReconcileChildren<T, TIndividualState extends FormControlSignal<T, any, any, any>>(factory: (val: DerivedSignal<T[], T>, idx: number, opt?: CreateFormControlOptions<T>) => TIndividualState, opt?: CreateFormControlOptions<T>) {
  return (length: number, source: WritableSignal<T[]>, prev?: TIndividualState[]): TIndividualState[] => {
    if (!prev) {
      const nextControls = [];

      for (let i = 0; i < length; i++) {
        nextControls.push(
          factory(
            derived(source, {
              from: (v) => v[i],
              onChange: (next) => source.update((cur) => cur.map((v, idx) => (idx === i ? next : v))),
            }),
            i,
            opt
          )
        );
      }

      return nextControls;
    }

    const next = [...prev];

    if (length < prev.length) {
      next.splice(length);
    } else if (length > prev.length) {
      for (let i = prev.length; i < length; i++) {
        next.push(
          factory(
            derived(source, {
              from: (v) => v[i],
              onChange: (next) => source.update((cur) => cur.map((v, idx) => (idx === i ? next : v))),
            }),
            i,
            opt
          )
        );
      }
    }

    return next;
  };
}

export function formArray<T, TIndividualState extends FormControlSignal<T, any, any, any> = FormControlSignal<T, any, any, any>, TParent = undefined>(initial: T[] | DerivedSignal<TParent, T[]>, factory: (val: DerivedSignal<T[], T>, idx: number, opt?: CreateFormControlOptions<T>) => TIndividualState, opt?: CreateFormArraySignalOptions<T, TIndividualState>): FormArraySignal<T, TIndividualState, TParent> {
  const eq = opt?.equal ?? Object.is;

  const arrayEqual = (a: T[], b: T[]) => {
    if (a.length !== b.length) return false;
    if (!a.length) return true;

    return a.every((v, i) => eq(v, b[i]));
  };

  const min = computed(() => opt?.min?.() ?? 0);
  const max = computed(() => opt?.max?.() ?? Number.MAX_SAFE_INTEGER);
  const elementLabel = computed(() => opt?.elementLabel?.() ?? "items");

  const validator = () =>
    arrayValidator<T[]>({
      min: min(),
      max: max(),
      elementLabel: elementLabel(),
    });

  const arrayOptions: CreateFormControlOptions<T[], "array"> = {
    ...opt,
    equal: arrayEqual,
    validator,
    dirtyEquality: (a, b) => a.length === b.length,
    controlType: "array",
  };

  const ctrl = formControl<T[], TParent, "array">(initial, arrayOptions) satisfies FormControlSignal<T[], TParent, "array">;

  const length = computed(() => ctrl.value().length);

  const reconcileChildren = createReconcileChildren<T, TIndividualState>(factory, opt);

  // linkedSignal used to re-use previous value so that only length changes are affected and existing controls are kept, but updated
  const children = linkedSignal<number, TIndividualState[]>({
    source: () => length(),
    computation: (len, prev) => reconcileChildren(len, ctrl.value, prev?.value),
  });

  const ownError = computed(() => validator()(ctrl.value()));

  const error = computed((): string => {
    if (ownError()) return ownError();
    if (!children().length) return "";
    return children().some((c) => c.error()) ? "INVALID" : "";
  });

  const dirty = computed(() => {
    if (ctrl.dirty()) return true;
    if (!children().length) return false;
    return children().some((c) => c.dirty());
  });

  const markAllAsTouched = () => {
    ctrl.markAllAsTouched();
    for (const c of untracked(children)) {
      c.markAllAsTouched();
    }
  };
  const markAllAsPristine = () => {
    ctrl.markAllAsPristine();
    for (const c of untracked(children)) {
      c.markAllAsPristine();
    }
  };

  const toPartialValue = opt?.toPartialValue ?? ((v: T) => v);
  const partialValue = computed(() => {
    if (!dirty()) return undefined;
    return children().map((c) => {
      const pv = c.partialValue();
      if (pv) return pv;
      if (c.controlType === "control") return undefined;

      // return full value for  child objects/arrays as this cannot be partially patched without idx
      return toPartialValue(c.value());
    });
  });

  const touched = computed(() => ctrl.touched() || !!(children().length && children().some((c) => c.touched())));

  const reconcile = (newValue: T[]) => {
    const ctrls = untracked(children);

    for (let i = 0; i < newValue.length; i++) {
      ctrls.at(i)?.reconcile(newValue[i]); // reconcile existing controls that are relevant addition/removal will be handled after ctrl.reconcile through linkedSignal
    }

    ctrl.reconcile(newValue);
  };

  const forceReconcile = (newValue: T[]) => {
    const ctrls = untracked(children);

    for (let i = 0; i < newValue.length; i++) {
      ctrls.at(i)?.forceReconcile(newValue[i]);
    }

    ctrl.forceReconcile(newValue);
  };

  return {
    ...ctrl,
    ownError,
    error,
    touched,
    children,
    dirty,
    markAllAsTouched,
    markAllAsPristine,
    min,
    max,
    partialValue,
    canAdd: computed(() => !ctrl.disabled() && !ctrl.readonly() && length() < max()),
    canRemove: computed(() => !ctrl.disabled() && !ctrl.readonly() && length() > min()),
    reconcile,
    forceReconcile,
    reset: () => {
      for (const c of untracked(children)) {
        c.reset();
      }
      ctrl.reset();
    },
    resetWithInitial: (initial: T[]) => {
      const ctrls = untracked(children);
      for (let i = 0; i < initial.length; i++) {
        ctrls.at(i)?.resetWithInitial(initial[i]);
      }
      ctrl.resetWithInitial(initial);
    },
    push: (next) => ctrl.value.update((cur) => [...cur, next]),
    remove: (idx) => ctrl.value.update((cur) => cur.filter((_, i) => i !== idx)),
  };
}

Angular material & NgModel

With the form primitives we've created, you can now create any form and bind directly to say an inputs value property, if you however wont/need to use ngModel, as you would in Angular Material's case, since the form-field is tightly coupled to it, you'll need to propagate the touched state separately as well as creating a validation directive. This can easily be done with an effect within the control component for example:

import { computed, Directive, effect, input } from "@angular/core";
import { NG_VALIDATORS, type Validator } from "@angular/forms";

@Directive({
  selector: "[ngModel][appProvidedError]",
  standalone: true,
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: ProvidedErrorValidator,
      multi: true,
    },
  ],
})
export class ProvidedErrorValidator implements Validator {
  readonly appProvidedError = input("");
  private onChange = () => {
    // noop
  };

  private readonly valid = computed(() => {
    return !this.appProvidedError();
  });

  constructor() {
    effect(() => {
      this.valid();
      this.onChange();
    });
  }

  validate() {
    return !this.valid() ? { providedError: this.appProvidedError() } : null;
  }

  registerOnValidatorChange(fn: () => void) {
    this.onChange = fn;
  }
}

@Component({
  selector: "app-text-input",
  imports: [FormsModule, ProvidedErrorValidator],
  template: `<input [(ngModel)]="state().value" [disabled]="state().disabled()" [readonly]="state().readonly()" [required]="state().required()" [appProvidedError]="state().error()" (blur)="state().markAsTouched()" />`,
})
export class TextInputComponent<TParent = undefined> {
  readonly state = input.required<FormControl<string | null, TParent>>();
}

In our case we abstract it a bit more so that it's easier to construct forms with reactive state, here's our string field component:

export type StringValidatorOpt = {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: string;
  not?: string[];
  blanks?: boolean;
  exact?: string | null;
  oneOf?: string[];
};

export type StringStateOpt = Omit<CreateFormControlOptions<string | null, "control">, "validator" | "required"> & {
  validation?: () => StringValidatorOpt;
  autocomplete?: () => AutoFill;
  placeholder?: () => string;
};

export function createStringState<TParent = undefined>(value: string | null | DerivedSignal<TParent, string | null>, opt?: StringStateOpt): StringState<TParent> {
  const validation = computed(() => opt?.validation?.() ?? {});

  // for the sake of brevity I've only provided the StringValidatorOpt interface, not the validators themselves
  const validator = () => stringValidator(validation());

  const state = formControl<string | null, TParent, "control">(value, {
    ...opt,
    validator,
    required: () => validation().required ?? false,
  }) as FormControlSignal<string | null, TParent, "control">;

  return {
    ...state,
    autocomplete: computed(() => opt?.autocomplete?.() ?? "off"),
    placeholder: computed(() => opt?.placeholder?.() ?? ""),
    type: "string",
  };
}

@Component({
  selector: "app-string-field",
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, MatFormFieldModule, MatInputModule, ProvidedErrorValidator, MatIcon],
  encapsulation: ViewEncapsulation.None,
  host: {
    "[class.app-string-field]": "true",
  },
  template: `
    <mat-form-field [appearance]="appearance()" [floatLabel]="floatLabel()" [subscriptSizing]="subscriptSizing()" [hideRequiredMarker]="hideRequiredMarker()">
      <mat-label>{{ state().label() }}</mat-label>

      @if (iconPrefix()) {
      <mat-icon matPrefix>{{ iconPrefix() }}</mat-icon>
      }

      <input matInput [(ngModel)]="state().value" [autocomplete]="state().autocomplete()" [disabled]="state().disabled()" [readonly]="state().readonly()" [required]="state().required()" (blur)="state().markAsTouched()" [placeholder]="state().placeholder()" [appProvidedError]="state().error()" />

      <mat-error>{{ state().error() }}</mat-error>

      @if (state().hint()) {
      <mat-hint>{{ state().hint() }}</mat-hint>
      }

      <ng-content />
    </mat-form-field>
  `,
  styles: `
        .app-string-field {
            display: contents;
        }
    `,
})
export class StringFieldComponent<TParent = undefined> {
  readonly appearance = input<MatFormFieldAppearance>(inject(MAT_FORM_FIELD_DEFAULT_OPTIONS, { optional: true })?.appearance ?? "outline");
  readonly floatLabel = input<FloatLabelType>(inject(MAT_FORM_FIELD_DEFAULT_OPTIONS, { optional: true })?.floatLabel ?? "auto");
  readonly subscriptSizing = input<SubscriptSizing>(inject(MAT_FORM_FIELD_DEFAULT_OPTIONS, { optional: true })?.subscriptSizing ?? "fixed");
  readonly hideRequiredMarker = input<boolean>(inject(MAT_FORM_FIELD_DEFAULT_OPTIONS, { optional: true })?.hideRequiredMarker ?? false);

  readonly iconPrefix = input<string>();

  readonly state = input.required<StringState<TParent>>();

  private readonly model = viewChild.required(NgModel);

  constructor() {
    effect(() => {
      if (!this.state().touched()) return;
      this.model().control.markAsTouched();
    });
  }
}

Conclusion

So we now have a great way of modeling reactive state & integrating it with forms, we've been using these primitives & abstractions for a few months now and after a bit of explanation to the team on what the purposes of derivations are, they've really started to love this approach in form state management. If you decide to give it a shot & have some input, or if you have any questions feel free to reach out :). Before I go I'd like to share an example of using these primitives fully, just so you can visualize how it integrates. Other than that I'll see you in the next article (hopefuly less than a year from now), happy coding! 🚀

import { booleanAttribute, Component, input, Signal, signal, WritableSignal } from "@angular/core";
import { createStringState, formArray, FormArraySignal, formGroup, FormGroupSignal, StringFieldComponent, StringState } from "./form";
import { derived } from "./form/primitives";

type User = {
  id: string;
  name: {
    first: string;
    last: string;
  };
  emails: string[];
};

function generateUser() {
  return {
    id: "2",
    name: {
      first: "Jane",
      last: "Smith",
    },
    emails: ["jane.smith@example.com", "jsmith@corporate.com"],
  };
}

type UserStateChildren = {
  firstName: StringState<User>;
  lastName: StringState<User>;
  emails: FormArraySignal<string | null, StringState<(string | null)[]>, User>;
};

type UserState = FormGroupSignal<User, UserStateChildren>;

function createUserFormState(signal: WritableSignal<User>, loading: Signal<boolean>): UserState {
  const children: UserStateChildren = {
    firstName: createStringState(
      derived(signal, {
        from: (v) => v.name.first,
        onChange: (next) =>
          signal.update((cur) => ({
            ...cur,
            name: { ...cur.name, first: next },
          })),
      }),
      {
        readonly: loading,
        label: () => "Last name",
        validation: () => ({
          required: true,
          blanks: true,
        }),
      }
    ),
    lastName: createStringState(
      derived(signal, {
        from: (v) => v.name.last,
        onChange: (next) =>
          signal.update((cur) => ({
            ...cur,
            name: { ...cur.name, last: next },
          })),
      }),
      {
        readonly: loading,

        label: () => "Last name",
        validation: () => ({
          required: true,
          blanks: true,
        }),
      }
    ),
    emails: formArray<string | null, StringState<(string | null)[]>, User>(
      derived(signal, {
        from: (v) => v.emails,
        onChange: (next) => signal.update((cur) => ({ ...cur, emails: next })),
      }),
      (derivation, idx) =>
        createStringState(derivation, {
          label: () => `Email ${idx + 1}`,
          validation: () => ({
            required: idx === 0,
            blanks: true,
          }),
        }),
      {
        max: () => 3,
        readonly: loading,
      }
    ),
  };

  return formGroup(signal, children);
}

@Component({
  selector: "app-user-form",
  imports: [StringFieldComponent],
  template: `
    <app-string-field [state]="state.children().firstName" />
    <app-string-field [state]="state.children().lastName" />

    <br />
    @for (ctrl of state.children().emails.children(); track ctrl.id; let i = $index) {
    <app-string-field [state]="ctrl" />
    <button (click)="state.children().emails.remove(i); state.children().emails.markAsTouched()">Remove</button>
    <br />
    }

    <button (click)="state.children().emails.push(null); state.children().emails.markAsTouched()">Add email</button>

    <p style="color: red">
      {{ state.children().emails.ownError() }}
    </p>
  `,
  styles: ``,
})
export class UserFormComponent {
  readonly user = signal(generateUser());
  readonly loading = input(false, { transform: booleanAttribute });
  readonly state = createUserFormState(this.user, this.loading);
}