I'm new to React 18 and Suspense. Nearly all of my previous web development was done in asp.net mvc. I want to click a button on a form, pass the form input values to a web api HttpGet method with the [FromQuery] attribute, and render the return into a div.
If I were doing this in asp.net mvc, I would wire up a button click event like so in javascript:
const btnSearch = document.getElementById('btnSearch');
btnSearch.addEventListener("click", function() {
executeMySearch();
return false;
});
And in the executeMySearch() method I'd grab the form input values, send them to server, fetch some html from the server and plunk it into a div like so:
const searchresults = document.getElementById('searchresults');
let formData = new FormData(document.forms[0]);
fetch('/Index?handler=MySearchMethod', {
method: 'post',
body: new URLSearchParams(formData),
}).then(function (response) {
return response.text();
}).then(function (html) {
searchresults.innerHTML = html;
Of course in React the approach is completely different, I showed the code above only to demonstrate what I want to happen. I want the search to execute only when the search button is clicked. My problem is, I cannot figure out how to manage React state to make that happen. Currently, after the search button is clicked once, my search is executing every time the user changes the value of a form input. I understand why that is happening, but I can't figure out how to structure my components so that the search executes only when the search button is clicked.
Server-side, my web api receives a form and returns a generic list, like so. This works fine:
[HttpGet("MySearchMethod")]
public async Task<List<MySearchResult>> MySearchMethod([FromQuery]MySearchForm mySearchForm)
{
return await _myRepository.GetMySearchResults(mySearchForm);
}
In my React app I have a search component. The component renders a form with the following elements:
Each select input is a React component that contains a list of enums fetched from the web api. Each select is defined in the search component like so:
const MyEnums = lazy(() => import('./MyEnums'));
Each of these React components is tied to the React state when the search component is defined, like so:
const MySearchComponent = () => {
const [myEnum, setMyEnum] = useState(0);
function onChangeMyEnum(myEnumId : number){
setMyEnum(myEnumId);
}...
and I tie my search button to React state like so:
const [isSearch, setIsSearch] = useState(false);
My search component returns a form with the search criteria and search button, and a div to contain the search results:
return (
<>
<form>
<div>
<ErrorBoundary FallbackComponent={MyErrorHandler}>
<h2>My Search Criteria Select</h2>
<Suspense fallback={<Spinner/>}>
<MyEnums onChange={onChangeMyEnum} />
</Suspense>
</ErrorBoundary>
</div>
<button className='btn btn-blue' onClick={(e) => {
e.preventDefault();
setIsSearch(true);
}
}>Search</button>
</form>
<div>
{
isSearch === true ?
<ErrorBoundary FallbackComponent={MyErrorHandler}>
<Suspense fallback={<Spinner/>}>
<MySearchResults myEnum={myEnum} [..other search criteria] />
</Suspense>
</ErrorBoundary>
: <span> </span>
}
</div>
Everything works fine. The problem is, after the first time the search button is clicked (which executes "setIsSearch(true)"), every time a user alters a selection in one of the form inputs, the search executes. I understand why. My "isSearch" variable remains true, so when the state is altered by the form input changing, and the component is re-rendered, the search happens again.
I tried passing the "setIsSearch" method into the MySearchResults component, and calling setIsSearch(false) after the component rendered, but that of course does exactly what it is supposed to to. The React state changes, the component re-renders, it sees that "isSearch" is false, and it makes the search results disappear. When I click my search button I see the search results flicker briefly and then disappear, which is exactly what should happen.
I also tried calling setIsSearch(false) every time a select changes, but of course this causes my search results to disappear, which is not desired.
What am I missing? How do I structure this so that the search only occurs when I click the Search button?
P.S. the web api call is made inside of the MySearchResults component when it renders. The MySearchResults component looks like this:
import React from 'react';
import { useQuery } from 'react-query';
import MySearchResult from './MySearchResult';
const fetchMySearchResults = async (myEnumId : number [...other criteria]) => {
let url = `${process.env.REACT_APP_MY_API}/GetMySearchResults/?myEnumId=${myEnumId}&[...other criterial]`;
const response = await fetch(url);
return response.json();
}
const MySearchResults = (props : any) => {
const {data} = useQuery(['myquery', props.myEnum,...other search criteria...]
,() => fetchMySearchResults(props.myEnun [...other search criteria]),
{
suspense:true
});
return (
<>
<table>
<thead>
<tr>
<th>My Column Header</th>
<th>...and so on</th>
</tr>
</thead>
<tbody>
{data.map((mySearchResult: {
...and so on
</tbody>
</table>
</>
);
};
export default MySearchResults;
Most of the answer is this--if you don't want the component to re-render, don't set a variable with useState(). Employ useRef() instead.
So, each select component's change handler is defined like this now:
const myEnum = useRef(0);
function onChangeMyEnum(myEnumId : number){
myEnum.current = myEnumId;
}
This is what it looked like before, when I (wrongly) called useState:
const [myEnum, setMyEnum] = useState(0);
function onChangeMyEnum(myEnumId : number){
setMyEnum(myEnumId);
}
And I pass these variables to my component the same way I did before:
<div>
<ErrorBoundary FallbackComponent={MyErrorHandler}>
<h2>My Search Criteria Select</h2>
<Suspense fallback={<Spinner/>}>
<MyEnums onChange={onChangeMyEnum} />
</Suspense>
</ErrorBoundary>
</div>
The prevents the problem of every change in the select causing the component to re-render.
The button click calls setIsSearch(true), but this state needs to be set "false" after the search results render so that subsequent button clicks cause a change in state which in turn causes the component to re-render. To do this add a call to useEffect, which is invoked after the component renders. [isSearch] is passed as the second argument to useEffect to prevent an infinite loop per the compiler warning:
useEffect(() => {
if(isSearch === true)
{
setIsSearch(false);
}
},[isSearch]);
The final piece of the puzzle is to pass the isSearch variable into the MySearchResults component. Per the @ZaeemKhaliq answer above, I added that to my useQuery call, so that I'm not going through the expense of running the query unless the user has clicked the search button. This is needed because useQuery cannot be run conditionally; that is to say, in the TemplateSearchResults component I can't simply put an if block above the useQuery statement and return if props.isSearch is false.
Here is what the MySearchResults component looks like now:
import React from 'react';
import { useQuery } from 'react-query';
import TemplateSearchResult from './TemplateSearchResult';
const fetchTemplates = async (carrierGroupId : number, stateId : number, policyTypeId : number, activityStatusCode: string) => {
let url = `${process.env.REACT_APP_PMSYS_API}/GetTemplates/?carrierGroupId=${carrierGroupId}&stateId=${stateId}&policyTypeId=${policyTypeId}&activityStatusCode=${activityStatusCode}`;
console.log('fetchTemplates: ' + url)
const response = await fetch(url);
return response.json();
}
const TemplateSearchResults = (props : any) => {
//fyi, you cannot return here if props.IsSearch===false, compiler barks at you. useQuery cannot be called conditionally.
const {data} = useQuery(['templates', props.carrierGroup,props.state,props.policyType,props.activityStatusCode]
,() => fetchTemplates(props.carrierGroup.current,props.state.current,props.policyType.current,props.activityStatusCode.current),
{
suspense:true,
enabled:props.isSearch
});
return (data === undefined ? <span>No search was done</span> :
<>
<table className='table-auto'>
<thead>
<tr>
<th></th>
<th>File Name</th>
<th>Groups</th>
<th>Types</th>
<th>Title</th>
<th>Form</th>
<th>State</th>
<th>Active</th>
<th>Required</th>
<th>Client Specific</th>
<th>Checked Out</th>
</tr>
</thead>
<tbody>
{data.map((templateSearchResult: {
active : boolean ,
carrierGroupNumbers: string,
checkedOut : string,
fileName: string,
formType: string,
isClientSpecific: boolean,
policyTypes: string,
required: boolean,
stateName: string,
templateId:number,
title:string}) => <TemplateSearchResult key={templateSearchResult.templateId}
active={templateSearchResult.active}
carrierGroupNumbers={templateSearchResult.carrierGroupNumbers}
checkedOut={templateSearchResult.checkedOut}
fileName={templateSearchResult.fileName}
formType={templateSearchResult.formType}
isClientSpecific={templateSearchResult.isClientSpecific}
policyTypes={templateSearchResult.policyTypes}
required={templateSearchResult.required}
stateName={templateSearchResult.stateName}
templateId={templateSearchResult.templateId}
title={templateSearchResult.title}/>)}
</tbody>
</table>
</>
);
};
export default TemplateSearchResults;
On my parent search page I removed the "if" block around the rendering of the search results component. Instead it just renders, passing the "isSearch" variable so that the component can decide what to render:
<div className='w-auto'>
<ErrorBoundary FallbackComponent={TemplatesErrorHandler}>
<Suspense fallback={<Spinner/>}>
<TemplateSearchResults isSearch={isSearch} carrierGroup={carrierGroup} state={state} policyType={policyType} activityStatusCode={activityStatusCode} />
</Suspense>
</ErrorBoundary>
</div>
Wow. All of this reminds me of the old criticism of nuclear power: "helluva complex way to boil water." This is one helluva complex way to generate html.