0

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:

  • four selects, which contain the search criteria. These selects are wrapped in React components.
  • a search button
  • a component that renders the search results

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>&nbsp;</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;
Tom Regan
  • 3,580
  • 4
  • 42
  • 71
  • 1
    Where is search function (executeMySearch()) actually executed? – Zaeem Khaliq Nov 11 '22 at 15:40
  • @ZaeemKhaliq I added the component that renders the search results. The search occurs when the component is rendered. – Tom Regan Nov 11 '22 at 15:52
  • 1
    You don't need to do that in `MySearchResults`. Just do it on Search button's click event handler. Since you are using `react-query`, there must be some flag or some way to manually trigger fetching. And after your request fulfills you can simply render MySearchResults if there is some `data` returned. – Zaeem Khaliq Nov 11 '22 at 15:55
  • @ZaeemKhaliq when you say "there must be some flag or some way to manually trigger fetching." I don't know how to do that. That is the question. How do you do that? – Tom Regan Nov 11 '22 at 16:00

3 Answers3

1

Move the useQuery hook up from MySearchResults to MySearchComponent. Use the enabled flag in your useQuery function, which is the flag which prevents the query from automatically running: -

const {data} = useQuery(['myquery', props.myEnum,...other search criteria...]
    ,() => fetchMySearchResults(props.myEnun [...other search criteria]),
    {
        enabled: isSearch,
        suspense:true
    }
);

When isSearch is set to true (which you do when you click on Search button), the query will execute. And when you get data then render your MySearchResults component providing data as props.

Afterwards, if you want to trigger search function again, you can use refetch function provided by useQuery hook in your Search button's event handler like so: -

const {data, refetch} = useQuery();
const onSearchClick = (e) => {
  e.preventDefault();
  if(isSearch === true){
    refetch();  
  }else{
    setIsSearch(true);
  }
}

For full reference, visit here: -

https://react-query-v3.tanstack.com/reference/useQuery

Zaeem Khaliq
  • 296
  • 5
  • 14
  • Thank you! I did not know of the "enabled" attribute. I think that will fix my problem. I'm going to try passing isSearch into the MySearchResults component first, I'll post again. Thank you, that "enabled" attribute is the bit of information I was missing. – Tom Regan Nov 11 '22 at 16:21
  • I'm afraid this still does not work. I did gain some new insight, but I realize my fundamental problem is this: I want access to the changed state of the search criteria when my search button is clicked, but I don't want the changed state of the search criteria to cause my search results to re-render. I need to somehow separate the state of the search criteria from the state of the button click. – Tom Regan Nov 11 '22 at 19:49
  • 1
    @TomRegan Most probably it's because in your `useQuery` hook, you have your search criteria (props.myEnum) as Query Key. Whenever query key changes (your search criteria changes), it is refetched (it's written in the library's documentation). What you can do is to store Search criteria in a separate state and manage a different state as a Query Key e.g. `const [queryKey, setQueryKey] = useState(null)`. When you click on Search button, combine the search criteria inside the event handler and set it as queryKey. Now only `queryKey` changes and only after that refetching will occur. – Zaeem Khaliq Nov 11 '22 at 21:32
  • Thanks @ZaeemKhaliq. I posted my answer below. Your advice to use the "enabled" attribute in the useQuery call is what led me to the solution, thank you very much for your advice. – Tom Regan Nov 11 '22 at 21:56
1

Use 2 useState:

const [myEnum, setMyEnum] = useState(0);
const [searchEnum, setSearchEnum] = useState();
setIsSearch(true);

    function onChangeMyEnum(myEnumId : number){
        setMyEnum(myEnumId);
    }

<Suspense fallback={<Spinner/>}>
     <MyEnums onChange={onChangeMyEnum} />
</Suspense>

<button className='btn btn-blue' onClick={(e) => {
                e.preventDefault();
                setSearchEnum(myEnum)
                setIsSearch(true);

            }
                }>Search</button>

{
            isSearch === true ?
                <ErrorBoundary FallbackComponent={MyErrorHandler}>
                    <Suspense fallback={<Spinner/>}>
                        <MySearchResults myEnum={searchEnum} [..other search criteria] />
                    </Suspense>
                </ErrorBoundary>
            : <span>&nbsp;</span>
            }

if you use only myEnum every time someone types a new value it triggers a rendering which triggers your search. If you separate the input and the search by setting the value that trigger the search when you click on the button your user can type in the input but only the click will trigger the search. Because only the click will change the value that the search is listening too.

Letincel
  • 197
  • 1
  • 8
  • Thanks for the suggestion @Letincel, but that is what I am already doing. The component re-renders when the state changes. Your statement "Because only the click will change the value that the search is listening too" is not accurate. Changing the select changes the state of the component, which causes a re-render. B – Tom Regan Nov 11 '22 at 16:53
0

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.

Tom Regan
  • 3,580
  • 4
  • 42
  • 71