Why Manage Form State Externally?

Is component-local state always the right home for form data? The existence of imperative APIs like setValue() and disable() suggests otherwise. Yet invoking methods manually to sync a form with external changes feels reminiscent of jQuery-era DOM manipulation. A more Angular-idiomatic approach leans on reactivity and declarative bindings rather than step-by-step commands.

Consider situations where lifting form state out of the component makes sense:

Saved Progress

Applications where users juggle multiple in-progress forms — think draft emails or multi-step checkouts — demand frequent, externally driven updates to what the form displays. Centralizing that state makes these updates straightforward.

Undo/Redo

Users often expect to revert changes across several fields, not just the last keystroke. Implementing undo/redo boils down to replacing the entire form state with a previous snapshot, which is trivial when the state lives in a store.

Time and Realtime

Forms tied to dynamic data — a bid that adjusts as an auction closes or a scheduling form reflecting an imminent deadline — must react to asynchronous changes. An external state source integrates naturally with such streams.

Server-Side Events

When the underlying record changes elsewhere — another collaborator updates it, an item goes out of stock, or a status flips — the user's form must stay in sync. Failing to do so risks submitting stale information.

Complexity

Intricate forms are difficult to reason about. Tooling that visualizes state transitions, such as Redux Devtools, becomes invaluable. The threshold for when this tooling justifies the setup cost is a judgment call — but external state management often pays for itself as complexity grows.

The Simple Way

Contrary to the perceived overhead, wiring a form into a state management library is surprisingly quick. Here's how to achieve it with NgRx and StateAdapt — the latter being a library I recently released, and one I think you'll appreciate.

If you're already familiar with these libraries, jump straight to steps 5 through 7 for the form-specific parts.

Step 1 (NgRx and StateAdapt)

Define the shape of the state and its initial value:

// form-state.interface.ts
export interface FormState { // Whatever it is
  name: string;
  age: number;
}

export const initialState: FormState = { name: '', age: 0 };
Enter fullscreen mode Exit fullscreen mode

Step 2 (NgRx only)

Declare the action that will carry update payloads:

// form.actions.ts
import { createAction, props } from '@ngrx/store';
import { FormState } from './form-state.interface';

export const formValueChange = createAction(
  '[Form] Value Change',
  props<FormState>()
);
Enter fullscreen mode Exit fullscreen mode

Step 3 (NgRx only)

Write the reducer that handles the action:

// form.reducer.ts
import { Action, createReducer, on } from "@ngrx/store";
import { FormState, initialState } from "./form-state.interface";
import { formValueChange } from "./form.actions";

const formReducer = createReducer(
  initialState,
  on(formValueChange, (state, { type, ...update }) => ({ ...state, ...update }))
);

export function reducer(state: FormState | undefined, action: Action) {
  return formReducer(state, action);
}

Enter fullscreen mode Exit fullscreen mode

Step 4 (NgRx only)

Register the reducer in your state or reducer tree at the desired location. For details, see the official NgRx Docs.

Step 5

NgRx

Add these imports to the file that contains your form component:

import { using } from 'rxjs';
import { tap } from 'rxjs/operators';
import { formValueChange } from './form.actions';
Enter fullscreen mode Exit fullscreen mode

Insert this logic into the component class:

  // this.form is the formGroup you created for the form
  formValues$ = using(
    () =>
      this.form.valueChanges
        .pipe(tap(values => this.store.dispatch(formValueChange(values))))
        .subscribe(),
    () => this.store.select(state => state.form) // Wherever you put it in your state tree
  );
Enter fullscreen mode Exit fullscreen mode

StateAdapt

Add these imports to the file that contains your form component:

import { toSource } from '@state-adapt/rxjs';
import { adapt } from '@state-adapt/angular';
import { initialState } from './form-state.interface';
Enter fullscreen mode Exit fullscreen mode

Insert this logic into the component class:

  // this.form is the formGroup you created for the form
  valueChanges$ = this.form.valueChanges.pipe(
    toSource('[Form] Value Change'),
  );
  formValues$ = adapt(initialState, {
    sources: { update: this.valueChanges$ },
  });
Enter fullscreen mode Exit fullscreen mode

Step 6 (NgRx and StateAdapt)

Add this directive to your module's declarations:

// patch-form-group-values.directive.ts
import { Directive, Input } from "@angular/core";

@Directive({
  selector: "[patchFormGroupValues]"
})
export class PatchFormGroupValuesDirective {
  @Input() formGroup: any;
  @Input()
  set patchFormGroupValues(val: any) {
    if (!val) return;
    this.formGroup.patchValue(val, { emitEvent: false });
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 7 (NgRx and StateAdapt)

Apply the directive directly to the <form> element in your template:

<form [formGroup]="form" [patchFormGroupValues]="formValues$ | async">
  <input type="text" formControlName="name" />
  <input type="number" formControlName="age" />
</form>
Enter fullscreen mode Exit fullscreen mode

Recap of the Straightforward Approach

You can try out working examples for NgRx and StateAdapt on StackBlitz. If you open Redux Devtools and interact with the form, you will see the state updates appear. It works.

A notable observation is that StateAdapt skipped Steps 2–4 entirely. Here is a comparison between the NgRx and StateAdapt implementations:

Angular Reactive Forms diff between NgRx and StateAdapt

Is StateAdapt losing anything by being this compact? No. It provides all the same layers as NgRx; each one is simply less verbose.

If you want more details, you can read the introduction to StateAdapt here.

The More Detailed Approach

The basic method only records a single action type in Redux Devtools:

Action types are all the same

For larger forms, you will likely want more granular visibility into what is changing. The core pattern from the simple method provides the foundation: for each property in FormState, create a dedicated action and extend the reducer to respond to it. If you are working with multiple form groups, you can apply PatchFormGroupValues to each one. But when you want an action for every individual control, you will need a separate directive. That is where the SetValue directive comes in:

// set-value.directive.ts
import { Directive, Input } from "@angular/core";
import { NgControl } from "@angular/forms";

@Directive({
  selector: "[setValue]"
})
export class SetValueDirective {
  @Input()
  set setValue(val: any) {
    this.ngControl.control.setValue(val, { emitEvent: false });
  }

  constructor(private ngControl: NgControl) {}
}

Enter fullscreen mode Exit fullscreen mode

Its usage is straightforward:

<form>
  <input type="text" [formControl]="name" [setValue]="name$ | async" />
  <input type="number" [formControl]="age" [setValue]="age$ | async" />
</form>
Enter fullscreen mode Exit fullscreen mode

Inside the component, you would subscribe to the valueChanges of each control and, if you are using NgRx, set up a separate using call for each one. I will not include all of the code here, but there is a complete example on StackBlitz for StateAdapt. The outcome gives you more insight into exactly what is being updated:

Action types are different

NgRx

valueChanges is not the only input you can use. Multiple sources can be connected in the same fashion. Instead of declaring them within the using, you define them outside and combine them using an RxJS merge so that each one gets subscribed to and dispatches to the store:

  valueChanges$ = this.form.valueChanges.pipe(
    tap(values => this.store.dispatch(formValueChange(values)))
  );
  delayedFormState$ = timer(5000).pipe(
    tap(() =>
      this.store.dispatch(delayedFormStateRecieved({ name: "Delayed", age: 1 }))
    )
  );
  formValues$ = using(
    () => merge(this.valueChanges$, this.delayedFormState$).subscribe(),
    () => this.store.select(state => state.ngrx) // Wherever you put it in your state tree
  );
Enter fullscreen mode Exit fullscreen mode

delayedFormStateRecieved behaves like formValueChange but with its own action type. The reducer was extended to handle both actions in the same way:

  on(
    formValueChange,
    delayedFormStateRecieved,
    (state, { type, ...update }) => ({ ...state, ...update })
  )
Enter fullscreen mode Exit fullscreen mode

StateAdapt

In StateAdapt, wherever you can provide a single source, you can also provide an array of sources. Since both of our sources emit values with the same interface and trigger the same state update, we can use an array:

  delayedFormState$ = timer(5000).pipe(
    map(() => ({ name: "Delayed", age: 1 })),
    toSource("[Form] Delayed Form State Received")
  );
  formValues$ = adapt(initialState, {
    sources: {
      update: [this.valueChanges$, this.delayedFormState$],
    },
  });
Enter fullscreen mode Exit fullscreen mode

Adaptability

The multi-source scenario highlights the adaptability of functional reactive programming. You can connect any source that produces values of the expected shape, regardless of its origin, and the source does not need to know how its output will be consumed. This means you can swap out an implementation entirely without touching any of this code.

The flexibility comes from having all the form state logic co-located. This stands in contrast to the imperative approach seen in jQuery, Angular Reactive Forms, and similar tools, where each event source or callback must encode its own meaning for the rest of the application. Imperative programming trades separation of concerns for careful ordering of code execution, and the more asynchronous the application, the more that trade-off undermines separation of concerns.

When enabling Redux Devtools for a form is this effortless, it is hard to imagine many cases where you would skip it. NgRx might feel like too much boilerplate for many forms, but adding StateAdapt to an existing NgRx or NGXS setup only takes about four lines of code to turn on Devtools for a form. You also gain a much more reactive and declarative foundation for managing form state going forward.

Following the same pattern, you can also control other form control attributes with directives. For instance, I shared a ControlDisabled directive in my previous article that you can use.

To learn more about StateAdapt, feel free to read the introductory post or visit the website.