DEV Community

Cover image for Why and how to manage state for Angular Reactive Forms
Mike Pearson for This is Angular

Posted on • Edited on

Why and how to manage state for Angular Reactive Forms

Should form state always be local? Some believe so, but methods like setValue() and disable() indicate that at least sometimes you need to manage form state from the outside. But these methods are imperative and reminiscent of jQuery, so usually it is better to have something more declarative and reactive like the rest of Angular.

Before we look at how to accomplish this, let's review some scenarios where reactive state management is especially helpful:

Saved Progress

This is any app where users spend a lot of time in a form and switch between multiple drafts, such as email clients with draft emails. This requires a lot of external state updates to the form.

Undo/Redo

Undo/Redo across multiple form elements can be convenient for users, and it requires completely swapping out form state multiple times.

Time and Realtime

Sometimes realtime data needs to be reflected in a form on an ongoing basis, like when a user is entering a bid for an item with frequent price updates, or scheduling something in the near future.

Server-Side Events

Any time something happens to a data model that the user is editing, you may need to update the state of the form. These events could be another user editing the same data, an item selling out, some status change, or anything that would cause a problem if the user tried to submit outdated data.

Complexity

Complex forms are hard to understand, but Redux Devtools can help a lot. You have to personally decide when a form is complex enough to justify the time to set up external state management.

How?

The Simple Way

Setting up external state management for forms might take a lot less time than you might think. I'm going to explain how to do it with both NgRx and StateAdapt because I just released StateAdapt and want you to know how cool it is 😁

Skip to Steps 5-7 if you want to only see the stuff that is related to forms.

Step 1 (NgRx and StateAdapt)

Create the form state interface and initial state:



// 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)

Create this action:



// 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)

Create the reducer:



// 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)

Plug the reducer into the reducer/state tree, wherever you want it to show up (see NgRx Docs).

Step 5

NgRx

Add these imports to the file of the component containing the form:



import { using } from 'rxjs';
import { tap } from 'rxjs/operators';
import { formValueChange } from './form.actions';


Enter fullscreen mode Exit fullscreen mode

Add this code inside 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 of the component containing the form:



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


Enter fullscreen mode Exit fullscreen mode

Add this code inside 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)

Drop this directive into your module:



// 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)

Use the new directive in your component template:



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


Enter fullscreen mode Exit fullscreen mode

Simple Way Review

Here are working StackBlitz examples for NgRx and StateAdapt. Open up Redux Devtools and watch as you edit the form. Success!

Notice that StateAdapt didn't require Steps 2-4. Check out the diff between NgRx and StateAdapt:

Angular Reactive Forms diff between NgRx and StateAdapt

What is StateAdapt missing that makes it so minimal? Nothing. It has every layer NgRx has; each layer is just thinner.

Read more about StateAdapt here if you are interested.

The Advanced Way

The simple method only puts one action type in Redux Devtools:

Action types are all the same

You will probably want something more descriptive if your form is large.

The basic pattern is established in the simple method above, so if you want to extend it, you will just need to create an action for each property of FormState and enhance the reducer to handle each action. If you have multiple form groups, you can use PatchFormGroupValues on each of them. If, however, you are defining an action for each form control, you need a new directive. Here is where you can use the SetValue directive:



// 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

It is used as you would imagine:



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


Enter fullscreen mode Exit fullscreen mode

In the component you would listen to the valueChanges of each form control and have a using call for each if you are using NgRx. I won't paste all the code here, but I do have a working example in StackBlitz for StateAdapt. The result is a little more detail about what is happening:

Action types are different

Multiple Sources

NgRx

valueChanges is just one possible source. We can plug in multiple sources in the same way. Rather than define them inside the using, we will define them outside and bundle them together with an RxJS merge so they all get subscriptions and dispatch 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 is the same as formValueChange but with a different action type. I extended the reducer to handle both actions the same way:



on(
formValueChange,
delayedFormStateRecieved,
(state, { type, ...update }) => ({ ...state, ...update })
)

Enter fullscreen mode Exit fullscreen mode




StateAdapt

In StateAdapt everywhere you can plug in one source you can also plug in an array of sources. Both of our sources will emit values with the same interface and affect the same state change, so we will use an array here:



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




Flexibility

This example of multiple sources illustrates the flexibility of functional reactive programming. You can plug in any source that emits the right kind of values without caring where they came from, and without the source caring how exactly you plan on using it. This means you can completely change its implementation without changing any of this code.

The flexibility comes from the fact that all of the business logic for our form state is located together. This is much more flexible than the imperative style in jQuery, Angular Reactive Forms and others where each event source (or callback function) has to define the full extent of its own meaning to other areas of the app. Imperative programming is a violation of separation of concerns in favor of separation of code execution timing. The more asynchronous your application is, the more imperative programming violates separation of concerns.

Conclusion

When it is this easy to get Redux Devtools working for a form, I do not see many situations where you would not want to use it. Maybe NgRx is too much setup for a lot of forms, but if you add StateAdapt to your NgRx or NGXS project, you really only need to add 4 or so lines of code to enable Redux Devtools for a form. Plus, you have a much more reactive and declarative foundation for managing form state in the future!

You can control other attributes of form controls with directives, too, if you follow the same pattern. For example, I have a ControlDisabled directive in my last blog post you can use.

If you are interested in learning more about StateAdapt, please read my introduction post or visit the website.

Top comments (4)

Collapse
 
oz profile image
Evgeniy OZ

Consider adding some examples of how this can be used for mentioned cases, like β€œundo” or β€œdrafts”. From current code examples, it's not obvious what is the difference related to usual form.valueChanges() and form.stateChanges().

Collapse
 
mfp22 profile image
Mike Pearson

I thought it was clear how you might change state in other ways. You've got form state in a reducer, and I assumed everyone could imagine creating another action modifying that state. However, seeing that it's kind of the central point of the article, I should probably add a quick example of how to do it. The point isn't any specific pattern on top, but that it would be easy to add something, so that's what I should show.

Collapse
 
xephylon profile image
Xephy Lon • Edited

Surprised you didn't mention NGXS Form Plugin. ngxs.io/plugins/form

Collapse
 
mfp22 profile image
Mike Pearson

It's nice because it easily gets form state into Redux Devtools. However, that's just about where the benefits end. Note that UpdateFormValue is basically synonymous with patchState. I will be adding an example soon to this article that shows why reactive state management is more flexible.