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 };
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>()
);
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);
}
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';
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
);
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';
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$ },
});
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 });
}
}
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>
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:
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:
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) {}
}
Its usage is straightforward:
<form>
<input type="text" [formControl]="name" [setValue]="name$ | async" />
<input type="number" [formControl]="age" [setValue]="age$ | async" />
</form>
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:
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
);
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 })
)
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$],
},
});
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.



