Blog>>Software development>>Frontend>>Redux and NgRx forms for optimizing form state management

Redux and NgRx forms for optimizing form state management

Have you ever wondered if keeping the form state in Redux makes any sense?

In programming, it is often said that the main questions can be answered with the famous phrase, "it depends." Based on my experience, I would agree: the answer truly depends on the specific situation. However, I would like to present and describe certain cases in which it is worth implementing the form state with NgRx forms. Additionally, I will highlight cases that are not worth implementing and provide a bonus solution, though a highly adverse one.

The decision to use Redux can significantly impact the overall effectiveness of an application. The below use cases are great examples of when this solution can prove to be a valuable addition.

What is Redux?

Redux is a predictable state management coding design pattern for JavaScript applications, commonly used with frameworks such as React, Angular or VUE. It provides a centralized way of managing the application state, making developing and maintaining complex applications easier. Example libraries supporting Redux are MobX for React, NgRx for Angular and Pinia for VUE

At its core, Redux follows the principles of a unidirectional data flow architecture. It helps to enforce a single source of truth for the application state, which means that the entire state of the application is stored in a single JavaScript object called the "store" that acts as a centralized hub.

The key concept behind Redux is that the state is read-only and can only be modified through pure functions called "reducers." A reducer takes the current state and an action as inputs and returns a new state based on those inputs. 

Redux notifies all registered reducers when an action is dispatched, which will then update the state accordingly. This predictable and deterministic behavior ensures that the state transitions are easily traceable, making it easier to debug and understand how changes occur.

Redux also supports the middleware concept, allowing for adding functionality to the dispatch process. 

Middleware sits between dispatching an action and the moment it reaches the reducers – it can be used for logging, asynchronous operations, routing, and more.

One of the main benefits of Redux is its ability to handle complex application states and manage state changes efficiently. By maintaining a single source of truth and enforcing strict rules for state modifications, Redux helps developers build scalable and maintainable applications.

It's important to note that Redux is not always necessary for all projects. It shines when you have complex state management requirements or a need to share the state between multiple components across your application. Simpler alternatives like React's built-in state management might be more appropriate for smaller applications or those with simpler state management needs.

As I mentioned above, there is more than one approach to Redux implementation. For more insights related to this topic, check out my previous article about Angular component state management with RxAngular and NgRx

Overall, Redux provides a robust solution for managing the application state and has become a popular choice in the JavaScript ecosystem, particularly for larger and more complex projects.

Services Frontend development

Form state consistency with Redux

Consistency is one of my personal priorities for building good quality frontend applications. If you already have state management in your application it may be worth considering to keep it consistent and implement state management in forms as well. Besides consistency you also gain the possibility of easily extracting the business logic from the form component to the Redux layer, which in my opinion is a bonus. 

Redux for unit testing 

The Redux pattern is really well suited for unit testing and this is because it’s heavily based on a pure function approach. Matching the dots here we get an easily unit testable form business logic layer. Anyone who has not been saved from a production crash by a good unit test, please feel free to cast the first stone. Just kidding here, but please prioritize writing your unit tests.

Form control with NgRx formS

One of the most significant advantages of incorporating NgRx forms is when there is an independent entity that interacts with the form fields involved. The code example provided below illustrates this particular scenario. 

To shed further light on the concept of an independent entity, consider a situation where you are shopping online. You have selected several products for purchase and are ready to proceed with the transaction. However, just before making the payment, you encounter an error stating that one of the products is out of stock because someone else was quicker to make the purchase. 

This is an example of an independent entity interfering with the form fields. Similar situations can arise with changes in stock availability or currency exchange rates, where dynamic alterations are involved. As developers, we typically strive to keep the end user as informed as possible. By utilizing NgRx forms, we can easily employ effect layers to update the relevant form fields, thereby separating this logic from the form component itself.

Example case description

The application presented has some basic assumptions: 

  • it provides an opportunity to exchange Bitcoin for PLN (polish currency) and vice versa,
  • when a user changes manually the value of the Bitcoin field, PLN is updated automatically and when a user changes the PLN value manually, the Bitcoin is updated automatically,
  • The current Bitcoin exchange rate is provided by a service and it’s random mock value,
  • When the Bitcoin exchange rate is updated, form values are updated as well and if the user is focused on the PLN field the Bitcoin field is updated, when the user is focused on the Bitcoin field the PLN value is updated.

The challenge here is to handle form values in a smart way because even though there are only two form fields they are codependent and also updated by an external party, the Bitcoin exchange rate service.The application interface is presented on the user interface screen.

Fig.1: NgRx fms user interface
NgRx fms user interface

Example case code presentation 

To fully understand this part of the article Redux and NgRx knowledge is recommended. For the record, the exact setup that we’re dealing with is presented on package.json.

{
  "name": "form-state-with-ngrx-forms",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "watch": "ng build --watch --configuration development",
    "test": "ng test"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "^15.2.8",
    "@angular/cdk": "^15.2.8",
    "@angular/common": "^15.2.8",
    "@angular/compiler": "^15.2.8",
    "@angular/core": "^15.2.8",
    "@angular/forms": "^15.2.8",
    "@angular/material": "^15.2.8",
    "@angular/platform-browser": "^15.2.8",
    "@angular/platform-browser-dynamic": "^15.2.8",
    "@angular/router": "^15.2.8",
    "@ngrx/effects": "^15.4.0",
    "@ngrx/router-store": "^15.4.0",
    "@ngrx/store": "^15.4.0",
    "@ngrx/store-devtools": "^15.4.0",
    "ngrx-forms": "^8.0.0",
    "rxjs": "~7.5.0",
    "tslib": "^2.3.0",
    "zone.js": "~0.11.4"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^15.2.7",
    "@angular/cli": "~15.2.7",
    "@angular/compiler-cli": "^15.2.8",
    "@types/jasmine": "~4.0.0",
    "jasmine-core": "~4.3.0",
    "karma": "~6.4.0",
    "karma-chrome-launcher": "~3.1.0",
    "karma-coverage": "~2.2.0",
    "karma-jasmine": "~5.1.0",
    "karma-jasmine-html-reporter": "~2.0.0",
    "typescript": "~4.9.5"
  }
}

The first important point here is how easy it is to implement NgRx forms. Basically, all that is required is to set up a state reference in the store that will hold the form object and after that setup NgRx in the form component, please check out the state object configuration, selectors, form component class and form component template. By the way, CryptoService is a facade service and I really recommend using a facade pattern with state management, especially when your state is divided into feature state slices. 

import {createFormGroupState, FormGroupState} from "ngrx-forms";

export interface CryptoFormState {
  btc: number,
  pln: number
}

export interface CryptoState {
  btc: number | undefined;
  cryptoForm: FormGroupState<CryptoFormState>;
  lastUsedControl: string | undefined;
}

export const CRYPTO_FORM_ID = 'CRYPTO_FORM';

const initialCryptoFormState = createFormGroupState<CryptoFormState>(CRYPTO_FORM_ID, {
  btc: 0,
  pln: 0
});
export const initialState: CryptoState = {
  btc: undefined,
  cryptoForm: initialCryptoFormState,
  lastUsedControl: undefined,
};

Lis. state object configuration

import {createFeatureSelector, createSelector} from "@ngrx/store";
import {CryptoState} from "./crypto.state";

export const selectCryptoFeature = createFeatureSelector<CryptoState>('crypto');
export const selectBTC = createSelector(selectCryptoFeature, feature => feature.btc ? feature.btc : 0);
export const selectCryptoForm = createSelector(selectCryptoFeature, feature => feature.cryptoForm);
export const selectLastUsedControl = createSelector(selectCryptoFeature, feature => feature.lastUsedControl);

Lis. selectors

import { Component } from '@angular/core';
import {CryptoService} from "../../services/crypto.service";
import {Observable} from "rxjs";
import {CryptoFormState, CryptoState} from "../../stores/crypto.state";
import {FormGroupState} from "ngrx-forms";

@Component({
  selector: 'app-ngrx-form-page',
  templateUrl: './ng-rx-form-page.component.html',
  styleUrls: ['./ng-rx-form-page.component.scss']
})
export class NgRxFormPageComponent {

  btc$: Observable<CryptoState['btc']> = this.crypto.selectBtc();
  formState$: Observable<FormGroupState<CryptoFormState>> = this.crypto.selectCryptoForm();
  constructor(private crypto: CryptoService) {
  }
}

Lis. form component class

CryptoService is providing the BTC exchange rate data based on a random value, this is just to simplify the aspect of constantly changing data. In real-life applications, this part would be replaced by a service providing real data, but the part leading to CryptoActions.updateBitcoin would probably stay the same.

<div class="container">
  <mat-card class="card">

    <mat-toolbar class="full-width">
      <span>Buy Bitcoin</span>
      <mat-icon aria-hidden="false" aria-label="Example home icon" fontIcon="bolt"></mat-icon>
    </mat-toolbar>

    <mat-divider class="divider"></mat-divider>
    <ng-container *ngIf="formState$ | async as formState">
      <form class="form" [ngrxFormState]="formState">

        <mat-form-field appearance="outline" class="full-width">
          <input matInput disabled readonly value="1 BTC =  {{btc$ | async}} PLN">
        </mat-form-field>

        <mat-divider class="divider"></mat-divider>

        <mat-form-field appearance="outline" class="full-width">
          <mat-label>BTC</mat-label>
          <input [ngrxFormControlState]="formState.controls.btc" matInput type="number" value="0">
        </mat-form-field>

        <mat-form-field appearance="outline" class="full-width">
          <mat-label>PLN</mat-label>
          <input [ngrxFormControlState]="formState.controls.pln" matInput type="number" value="0">
        </mat-form-field>

        <mat-divider class="divider"></mat-divider>

        <button mat-raised-button color="primary" class="full-width">Buy BTC and sell PLN</button>
      </form>
    </ng-container>

  </mat-card>
</div>

Lis. form component template

import {Injectable} from '@angular/core';
import {interval, Observable, of, switchMap, tap, throttleTime} from "rxjs";
import {Store} from "@ngrx/store";
import {CRYPTO_FORM_ID, CryptoState} from "../stores/crypto.state";
import {CryptoActions} from "../stores/crypto.actions";
import {selectBTC, selectCryptoForm, selectLastUsedControl} from "../stores/crypto.selectors";
import {SetValueAction} from "ngrx-forms";

@Injectable({
  providedIn: 'root'
})
export class CryptoService {

  $btc = interval().pipe(
    throttleTime(3000),
    switchMap(() => this.getBTCPrice()),
    tap(btc => this.store.dispatch(CryptoActions.updateBitcoin({btc})))
  )

  constructor(private store: Store<CryptoState>) {
  }

  selectBtc = (): Observable<number> => this.store.select(selectBTC);
  selectCryptoForm = (): Observable<CryptoState['cryptoForm']> => this.store.select(selectCryptoForm);
  selectLastUsedControl = (): Observable<CryptoState['lastUsedControl']> => this.store.select(selectLastUsedControl);
  setLastUsedControl = (controlId: string): void =>  this.store.dispatch(CryptoActions.setLastUsedFormControl({controlId}));
  setCryptoFormValue = (pln: number, btc: number): void => this.store.dispatch(new SetValueAction(CRYPTO_FORM_ID, {btc, pln}));
  private getBTCPrice = (): Observable<number> => of(Number(`117${Math.floor(Math.random() * 1000)}`))
}

Lis. crypto service

After the configuration is done, let’s see what the end result is. To do so, the Redux DevTools extension is just perfect. Please check out NgRx Store Devtools. All the form object properties are reflected in the store state, including the controls and all properties for individual controls as well. At this point there is a possibility of manipulating the form object using Redux pattern techniques, like dispatching actions which are changing state and binding actions into action chains with the effects.

Fig.2: NgRx Store DevTools
NgRx Store DevTools

Now for the fun part, remember the application assumptions described earlier? All the business logic is programmed inside the effects.

import {Injectable} from "@angular/core";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {SetValueAction} from "ngrx-forms";
import {tap, withLatestFrom} from "rxjs";
import {CryptoService} from "../services/crypto.service";
import {CryptoActions} from "./crypto.actions";


@Injectable()
export class CryptoEffects {

  constructor(private actions$: Actions, private crypto: CryptoService) {
  }

  updateCryptoFormOnUserAction$ = createEffect(() => this.actions$.pipe(
    ofType(SetValueAction.TYPE),
    withLatestFrom(this.crypto.selectCryptoForm(), this.crypto.selectBtc()),
    tap(([action, form, btc]) => {
      const controlId = action['controlId'];
      if (controlId === form.controls.btc.id || controlId === form.controls.pln.id) this.crypto.setLastUsedControl(controlId);
      if (controlId === form.controls.btc.id) this.crypto.setCryptoFormValue(action['value'] * btc, action['value']);
      if (controlId === form.controls.pln.id) this.crypto.setCryptoFormValue(action['value'], action['value'] / btc);
    })
  ), {dispatch: false});

  updateCryptoFormOnCBitcoinChange$ = createEffect(() => this.actions$.pipe(
    ofType(CryptoActions.updateBitcoin),
    withLatestFrom(this.crypto.selectCryptoForm(), this.crypto.selectLastUsedControl()),
    tap(([action, form, controlId]) => {
      if (controlId === form.controls.btc.id) this.crypto.setCryptoFormValue(form.value.btc * action.btc, form.value.btc);
      if (controlId === form.controls.pln.id) this.crypto.setCryptoFormValue(form.value.pln, form.value.pln / action.btc);
    }),
  ), {dispatch: false});

}

updateCryptoFormOnUserAction$ handles the form whenever the user changes the form input value by hand. For example, devices buy BTC for the selected amount of PLN or the other way around. 

updateCryptoFormOnCBitcoinChange$ handles the case when CryptoService provides an updated value of BTC and the form value needs to be recalculated. For example, the user sets the PLN value he wants to spend on BTC and the BTC value that he is able to buy needs to be updated.

Testing

Testing was mentioned as important as well as an advantage of the Redux pattern approach. To remain fair, test case examples are presented below. As you can see the selectors and the reducer tests are based 100% on a pure function approach which makes them really straightforward to write and maintain. It is also worth mentioning that most of the state handling logic is based on actions, reducers and selectors.

describe('crypto.reducer.ts', () => {
  let newState: CryptoState = initialState;

  beforeEach(() => {
    newState = {...initialState}
  });

  describe('unknown action', () => {
    it('should return default state', () => {
      const action = {
        type: 'Undefined'
      }
      const state = cryptoReducer(initialState, action);
      expect(state).toBe(initialState);
    });
  });

  describe('updateBitcoin', () => {
    it('should update the btc value in the state', () => {
      const newBtcValue = 10;
      newState = {...newState, btc: newBtcValue};
      const action = CryptoActions.updateBitcoin({btc: newBtcValue});
      const state = cryptoReducer(initialState, action);
      expect(state).toEqual(newState);
      expect(state).not.toBe(initialState)
    });
  });

  describe('setLastUsedFormControl', () => {
    it('should update the lastUsedFormControl value in the state', () => {
      const newLastUsedFormControlValue = 'pln';
      newState = {...newState, lastUsedControl: newLastUsedFormControlValue};
      const action = CryptoActions.setLastUsedFormControl({controlId: newLastUsedFormControlValue});
      const state = cryptoReducer(initialState, action);
      expect(state).toEqual(newState);
      expect(state).not.toBe(initialState)
    });
  });
});
describe('crypto.selectors.ts', () => {
  const initState: CryptoState = initialState;
  const mockState: CryptoState = {
     btc: 2,
    lastUsedControl: 'pln',
    cryptoForm: cryptoFormStateMock
  }

  describe('selectCryptoFeature', () => {
    it('should return crypto feature slice (initial state)', () => {
      expect(S.selectCryptoFeature.projector(initState)).toEqual(initState);
    })
    it('should return crypto feature slice (mock state)', () => {
      expect(S.selectCryptoFeature.projector(mockState)).toEqual(mockState);
    })
  })

  describe('selectBTC', () => {
    it('should return btc value (initial state)', () => {
      expect(S.selectBTC.projector(initState)).toEqual(0);
    })
    it('should return btc value (mock state)', () => {
      expect(S.selectBTC.projector(mockState)).toEqual(2);
    })
  })

  describe('selectCryptoForm', () => {
    it('should return crypto form object (initial state)', () => {
      expect(S.selectCryptoForm.projector(initState)).toEqual(initState['cryptoForm']);
    })
    it('should return crypto form object (mock state)', () => {
      expect(S.selectCryptoForm.projector(mockState)).toEqual(cryptoFormStateMock);
    })
  })

  describe('selectLastUsedControl', () => {
    it('should return (initial state)', () => {
      expect(S.selectLastUsedControl.projector(initState)).toBeUndefined()
    })
    it('should return (mock state)', () => {
      expect(S.selectLastUsedControl.projector(mockState)).toEqual('pln');
    })
  })
})

Effects tests are a little bit more demanding, this is because a TestBed configuration needs to be provided. The configuration must provide mock actions and any other services that effects are using, including the store state if used. 

In my case, I decided to go with non-dispatching effects that delegate activities to a facade service, the CryptoService. This is because I really like the idea of having Redux actions and selectors wrapped in an independent service; it provides a lot of flexibility, especially in feature driven thinking.

Additionally, I needed to provide a mock store with selector mock values and CryptoService, of course. With non-dispatching effects the idea of testing is to use the spy and see if the proper side effect activity was triggered. Besides this, there are more ways to approach effects testing and the NgRx documentation has a lot of good examples. The complete thing can be examined below.

describe('crypto.effects.ts', () => {
  let actions$: Observable<Action> = new Observable<Action>();
  let effects: CryptoEffects;
  let cryptoService: CryptoService;
  const initialState: CryptoState = {btc: 111000, lastUsedControl: 'btc', cryptoForm: cryptoFormStateMock};

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        CryptoEffects,
        CryptoService,
        provideMockActions(() => actions$),
        provideMockStore({
          initialState,
          selectors: [
            {selector: selectBTC, value: 142000},
            {selector: selectCryptoForm, value: cryptoFormStateMock},
            {selector: selectLastUsedControl, value: 'CRYPTO_FORM.btc'}
          ]
        }),
      ],
    });

    effects = TestBed.inject<CryptoEffects>(CryptoEffects);
    cryptoService = TestBed.inject<CryptoService>(CryptoService);
  })

  describe('updateCryptoFormOnUserAction$', () => {
    it('should trigger setLastUsedControl and setCryptoFormValue when form btc value was updated', (done) => {
      actions$ = of(new SetValueAction('CRYPTO_FORM.btc', 3));
      spyOn(cryptoService, 'setLastUsedControl');
      spyOn(cryptoService,'setCryptoFormValue');
      effects.updateCryptoFormOnUserAction$.subscribe(() => {
        expect(cryptoService.setLastUsedControl).toHaveBeenCalledWith('CRYPTO_FORM.btc');
        expect(cryptoService.setCryptoFormValue).toHaveBeenCalledWith(426000,3);
        done();
      })
    });
    it('should trigger setLastUsedControl and setCryptoFormValue when form pln value was updated', (done) => {
      actions$ = of(new SetValueAction('CRYPTO_FORM.pln', 142000));
      spyOn(cryptoService, 'setLastUsedControl');
      spyOn(cryptoService,'setCryptoFormValue');
      effects.updateCryptoFormOnUserAction$.subscribe(() => {
        expect(cryptoService.setLastUsedControl).toHaveBeenCalledWith('CRYPTO_FORM.pln');
        expect(cryptoService.setCryptoFormValue).toHaveBeenCalledWith(142000,1);
        done();
      })
    });
  });

Summary 

Having the form state tracked with NgRx and NgRx forms is easy and that’s a fact. The question here is if it makes sense or not. In my opinion, based on experience with using this pattern in commercial applications, it makes a lot of sense. This is due to two main reasons. 

The first is that it allows for separating the business logic from the component layer, so the components may focus on the look and feel and the Redux engine on the logic, which is a good design pattern strongly corresponding to SOLID’s single responsibility principle. The second reason is that the Redux layer is in general easy to test because it’s based mostly on a pure functions approach. Effects may be a small exception but the NgRx documentation provides lots of examples and patterns of how effects may be tested as well. From the engineering point of view, testing has significant value and is often a lifesaver. There is also a third bonus reason with using the Redux DevTools, they provide an additional debugging tool set. 

To be fair, some weaknesses of this approach also need to be mentioned. The first one is definitely the documentation in my opinion. It’s efficient for setting up NgRx forms and has some basic examples but some actions that are quite essential, like SetValueAction, SetErrorsAction or ResetAction, are not stressed enough and the cases presented are really shallow. I also don’t find it intuitive that ResetAction marks the form as pristine, untouched, and unsubmitted, but does not set the values to the initial state. 

Whenever you need to truly reset all the form properties ResetAction and SetValueAction need to be combined. The second disadvantage is that if you’re not a Redux fan and see Redux as a boilerplate or overly complicated pattern then those arguments apply to NgRx forms as well.

Bibliography

Witek Michał

Michał Witek

Senior Frontend Engineer

Michał Witek is a senior frontend engineer and author on CodiLime's blog. Check out the author's articles on the blog. Read about author >

Read also

Get your project estimate

For businesses that need support in their software or network engineering projects, please fill in the form and we'll get back to you within one business day.