How to combine Custom / Async Availity reactstrap Validation with onChange attribute saving value to a useState Hook?

If you choose to save all form inputs with useState Hooks while using async validation, you will face this issue. Here is how to avoid multiple api calls and weird validation behaviors.


1. Setup

Here is the repository containing the solution, but I described the required setup steps in this article as well.

I re-created the issue we faced at work by generating both frontend and backend from template generators. For the frontend I used Create React App with typescript and for the backend I used ASP.NET Core webapi template.

Backend is not really important here, but we use this stack so it was easier to reason about how everything works.

For a quick setup, just create an empty folder and run the following commands

1
2
3
4
5
6
7
8
9
10
npx create-react-app test-react-validation --template typescript
dotnet new webapi -n test-react-validation-backend

cd test-react-validation

npm install --save reactstrap react react-dom
npm install --save @types/reactstrap
npm install --save availity-reactstrap-validation react react-dom
npm install --save lodash
npm install --save @types/lodash

Frontend setup

Since Availity reactstrap Validation doesn’t have types npm package, I had to modify react-app-env.d.ts with

1
declare module "availity-reactstrap-validation";

I created new file for the component as src/MyForm/MyForm.tsx with the following code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { FC, useState } from "react";
import { AvForm, AvField } from 'availity-reactstrap-validation';
import { Button } from "reactstrap";
import debounce from "lodash/debounce";

interface IWeatherForecast {
summary: string;
}

export const MyForm: FC = () => {
const [name, setName] = useState<string>("");
const [weatherSummary, setWeatherSummary] = useState<string>("");

return (
<div>
<AvForm>
<AvField name="name" label="Name" required onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setName(e.target.value) }} />
<AvField name="async" label="Async Validation (enter 'Warm')" type="text" validate={{async: validate}} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setWeatherSummary(e.target.value) }} />
<Button color="primary">Submit</Button>
</AvForm>
</div>
)
}

And I changed App.tsx to contain only the MyForm component

1
2
3
4
5
6
7
8
9
10
11
12
import React from 'react';
import logo from './logo.svg';
import './App.css';
import { MyForm } from './MyForm/MyForm';

const App: React.FC = () => {
return (
<MyForm />
);
}

export default App;

Of course, the code will not compile at this point because the implementation of the validate method is missing.

To start the frontend project, in the project root use the command

1
npm start

Backend setup

Backend app required some modifications to enable CORS based on the following Stack Overflow answer.

In Startup.cs you need to add the following to ConfigurationServices method

1
2
3
4
5
6
7
8
9
services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});

and the following to Configure method

1
app.UseCors();

And finally I changed WeatherForecastController.cs on line 34 to disable randomization, from

1
Summary = Summaries[rng.Next(Summaries.Length)]

to

1
Summary = Summaries[index]

To start the backend project, in the project root use the command

1
dotnet run

2. The issue

When I applied the solution suggested by Availity docs under Custom / Async

1
2
3
4
5
6
7
8
9
10
11
12
let validateTimeout: number = 0;
const validate = debounce((value: string, ctx: any, input: any, cb: any) => {
const validateAsync = async () => {
const result = await fetch("https://localhost:5001/WeatherForecast/");
const resultJson = await result.json() as IWeatherForecast[];
cb(resultJson.some(r => r.summary === value));
};
window.clearTimeout(validateTimeout);
validateTimeout = window.setTimeout(() => {
validateAsync();
}, 500);
}, 300);

I got the following behavior

As you can see, there are several issues here:

  • Validation was triggered on form load
  • On each key press validation was triggered more then once
  • Validation was triggered by entering value in a different field
  • Validation was triggered by clicking the Submit button even if the value wasn’t changed

I was typing fast in order to take advantage debounce. In this example, the api was called 8 times.

What is really happening here is that by calling onChange method, re-render of the whole component is triggered which also triggers the validation again.

3. The fix

A colleague suggested to use the useRef Hook and I used it to decorate the validate method. It stores the mutable value and doesn’t cause a re-render.

I also removed setTimeout because it was not needed.

1
2
3
4
5
6
7
8
9
10
const validate = useRef(
debounce((value: string, ctx: any, input: any, cb: any) => {
const validateAsync = async () => {
const result = await fetch("https://localhost:5001/WeatherForecast/");
const resultJson = await result.json() as IWeatherForecast[];
cb(resultJson.some(r => r.summary === value));
};
validateAsync();
}, 300)
).current;

Good progress! We are down to 4 api calls and onChange calls no longer trigger validate method!

What we want now is to track validation value so we don’t call the api more then once for the same value. We also don’t want to validate on the component load.

I used useRef Hook again to keep track of the value returned from the api and whether or not that resulted in a successfull validation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const validatedWeatherSummary = useRef({ value: undefined as string | undefined, valid: false }).current;

const validate = useRef(
debounce((value: string, ctx: any, input: any, cb: any) => {
const validateAsync = async () => {
const result = await fetch("https://localhost:5001/WeatherForecast/");
const resultJson = await result.json() as IWeatherForecast[];
validatedWeatherSummary.valid = resultJson.some(r => r.summary === value);
validatedWeatherSummary.value = value;
cb(validatedWeatherSummary.valid);
};
if (
(validatedWeatherSummary.value !== undefined && validatedWeatherSummary.value !== value) // Validate if different value
|| (value !== "" && validatedWeatherSummary.value === undefined) // Initial validation on first value change
) {
validateAsync();
} else {
cb(validatedWeatherSummary.valid);
}
}, 300)
).current;

The result can be seen here

Finally, only one api call! Since React Hooks are still pretty new, I hope that someone will find this usefull.


To take screenshots I use LightShot
To record gifs I use ScreenToGif