How to debounce mui Autocomplete - reactjs

I need to debounce onInputChange function
export interface AutocompleteProps<V extends FieldValues> {
onInputChange: UseAutocompleteProps<UserOrEmail, true, false, false>['onInputChange'];
}
export default function MyAutocomplete<V extends FieldValues>({
onInputChange
}: AutocompleteProps<V>) {
return (
<Controller
name={name}
control={control}
render={({ field: { onChange, value } }) => (
<Autocomplete<UserOrEmail, true, false, false>
onInputChange={onInputChange} //here
/>
)}
/>
);
I have done it like this(used debounce outside and passing it like a prop) and it works
const [search, setSearch] = useState('');
const searchDelayed = useCallback(
debounce((newValue: string) => setSearch(newValue), 100),
[]
);
//Passing the prop like this
/*
onInputChange={(_, newValue) => {
searchDelayed(newValue);
}}
*/
I want to debounce in MyAutocomplete but I can not call onInputChange in debounce function. If I do not call it then I get an error, and it does not work
const searchsDelayed = useCallback( //does not work
debounce(() => onInputChange, 100),
[onInputChange]
);
Any ideas how it can be done?

[Answer reviewed to avoid confusion.]
The main issue is here:
debounce(() => onInputChange, 100);
you are debouncing a function that returns onInputChange, it doesn't invoke it, and it would be needed to be called twice.
To fix this, just pass onInputChange directly:
debounce(onInputChange, 100);
Anyway, you are trying to use useCallback but you probably would be fine without any optimization anyway, so you could use
const searchDelayed = debounce(onInputChange, 100);
But if you actually need the optimization, in this case you should use useMemo since you need the result of the debounce invocation:
const searchDelayed = useMemo(
() => debounce(onInputChange, 100),
[onInputChange]
);

Related

Debouncing MUI Autocomplete not working properly

I'm trying to implement debounce for mui autocomplete,
but it's not working well.
I want to send a debounced request to server when inputValue change.
Am I miss something?
It looks loadData fired every input change. Only first load debounce works.
https://codesandbox.io/s/debounce-not-working-j4ixgg?file=/src/App.js
Here's the code from the sandbox:
import { useState, useCallback } from "react";
import { Autocomplete, TextField } from "#mui/material";
import { debounce } from "lodash";
import topFilms from "./topFilms";
export default function App() {
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState("");
const [options, setOptions] = useState([]);
const loadData = () => {
// sleep(1000)
const filteredOptions = topFilms.filter((f) =>
f.title.includes(inputValue)
);
// This log statement added by Ryan Cogswell to show why it isn't working.
console.log(
`loadData with ${filteredOptions.length} options based on "${inputValue}"`
);
setOptions(filteredOptions);
};
const debouncedLoadData = useCallback(debounce(loadData, 1000), []);
const handleInputChange = (e, v) => {
setInputValue(v);
debouncedLoadData();
};
const handleChange = (e, v) => {
setValue(v);
};
return (
<div className="App">
<Autocomplete
value={value}
inputValue={inputValue}
onChange={handleChange}
onInputChange={handleInputChange}
disablePortal
options={options}
getOptionLabel={(option) => option.title}
isOptionEqualToValue={(option, value) => option.title === value.title}
id="combo-box-demo"
renderInput={(params) => <TextField {...params} label="Movie" />}
/>
</div>
);
}
tldr: codesandbox link
You can accomplish this with a few hooks working together. First lets look at a hook for debouncing state:
function useDebounce(value, delay, initialValue) {
const [state, setState] = useState(initialValue);
useEffect(() => {
console.log("delaying", value);
const timer = setTimeout(() => setState(value), delay);
// clear timeout should the value change while already debouncing
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return state;
}
The above takes in a value and returns the debounced value from the hook later.
The callback at the end of the useEffect prevents a bunch of timers triggering one after the other.
Then your component can be reduced to:
export default function App() {
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState("");
const [options, setOptions] = useState([]);
const debouncedValue = useDebounce(inputValue, 1000);
// fetch data from server
useEffect(() => {
console.log("fetching", debouncedValue);
const filteredOptions = topFilms.filter((f) =>
f.title.includes(debouncedValue)
);
setOptions(filteredOptions);
}, [debouncedValue]);
const handleInputChange = (e, v) => {
setInputValue(v);
};
const handleChange = (e, v) => {
setValue(v);
};
return (
<div className="App">
<Autocomplete
// props
/>
</div>
);
}
The useEffect here is run when the dependency debouncedValue is changed via react state business.
When typing in the TextField your console should look like:
delaying s
delaying sp
delaying spi
fetching spi
delaying spir
fetching spir
This useEffect in App leaves you with a good place to do your fetch to a server like you mentioned you'll need.
The useEffect could easily be replaced with something like useQuery
In your sandbox, loadData was being successfully debounced, however it was always executed with inputValue === "" which then matched all of the options. All of the actual filtering was then being immediately done by the Autocomplete component just the same as if you had provided the full set of options statically. Due to the behavior of JavaScript closures, inputValue is always the empty string because that is its initial value as you create the debounced version of loadData.
Whenever creating debounced functions, I recommend declaring and defining the original function and the debounced version at the top level rather than inside your component. Any local variables and state that your debounced function is dependent on can be passed in as arguments to the function (e.g. in my version of your code, the input value and the setOptions function are passed to debouncedLoadData). This avoids accidentally using stale values due to closure behavior and removes the need for useCallback.
Below is a reworked version of your code that moves loadData and debouncedLoadData to the top level. I also changed loadData to provide no data (rather than all data) when it is passed an empty string so that the debounce behavior is easier to see -- otherwise whenever the Autocomplete has the full set of options, the filtering will occur immediately based on the options that the Autocomplete already has. This also more closely simulates what real code would likely do (i.e. avoid calling the back end for empty string).
import { useState } from "react";
import { Autocomplete, TextField } from "#mui/material";
import { debounce } from "lodash";
import topFilms from "./topFilms";
const loadData = (inputValue, setOptions) => {
if (inputValue.length === 0) {
console.log("no options");
setOptions([]);
return;
}
const filteredOptions = topFilms.filter((f) =>
f.title.toLowerCase().includes(inputValue.toLowerCase())
);
console.log(
`loadData with ${filteredOptions.length} options based on "${inputValue}"`
);
setOptions(filteredOptions);
};
const debouncedLoadData = debounce(loadData, 1000);
export default function App() {
const [value, setValue] = useState(null);
const [inputValue, setInputValue] = useState("");
const [options, setOptions] = useState([]);
const handleInputChange = (e, v) => {
setInputValue(v);
debouncedLoadData(v, setOptions);
};
const handleChange = (e, v) => {
setValue(v);
};
return (
<div className="App">
<Autocomplete
value={value}
inputValue={inputValue}
onChange={handleChange}
onInputChange={handleInputChange}
disablePortal
options={options}
getOptionLabel={(option) => option.title}
isOptionEqualToValue={(option, value) => option.title === value.title}
id="combo-box-demo"
renderInput={(params) => <TextField {...params} label="Movie" />}
/>
</div>
);
}

Rendered fewer hooks than expected. This may be caused by an accidental early return statement in React Hooks

The following component throws me this error message at runtime when I try to render the table as Input value (editable field) with the additional warning given below.
Warning: Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks. You can only call Hooks at the top level of your React function.
This is my code,
const EditableCell = (initialValue: any) => {
const [value, setValue] = React.useState(initialValue);
const onChange = (e: any) => {
setValue(e.target.value);
};
React.useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return <Input type="text" value={value} onChange={onChange} />;
};
const ParameterTable: React.FC<ParameterTableProps> = () => {
const {
decisionMetadataInput: input,
decisionMetadataOutput: output,
paramList
} = testData.data;
const inMeta = React.useMemo(() => input?.metadata ?? [], [input]);
const outMeta = React.useMemo(() => output?.metadata ?? [], [output]);
const allColumn = React.useMemo(
() =>
[...inMeta, ...outMeta].map((meta) => (
<Column
title={
<>
<span>{meta.field}</span>
<br />
<Typography.Text italic>({meta.type})</Typography.Text>
</>
}
key={meta.field}
dataIndex={["data", meta.field]}
render={(dataIndex) => EditableCell(dataIndex)}
/>
)),
[inMeta, outMeta]
);
const datasource = React.useMemo(
() =>
paramList.map((param) => {
const inParam = param?.paramInput?.param ?? [];
const outParam = param?.paramOutput?.param ?? [];
const data = [...inParam, ...outParam].reduce(
(prev, current) => ({
...prev,
[current.field]: current.value
}),
{}
);
return { data, num: param.paramOrder };
}),
[paramList]
);
return (
<Table dataSource={datasource} rowKey="num" pagination={false}>
<Column title="F" dataIndex="num" />
{allColumn}
</Table>
);
};
This is my codesandbox URL - https://codesandbox.io/s/react-typescript-forked-hl179m?file=/src/ParameterTable.tsx
I suspect this happens because I call the function EditableCell inside the useMemo hook, however I'm not sure how to resolve this. Any help/ suggestion would do, thanks.
I'm trying to make the Antd React Table as editable fields, however when I tried to map the dataIndex as value in a seperate function to render in the column I get this 'Uncaught Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement in React Hooks'
Instead of calling EditableCell as if it were a plain function, wrap it in React.createElement so that React can see that it's a component (and can therefore have hooks called inside it).
Change to
render={(dataIndex) => <EditableCell initialValue={dataIndex} />}
and
const EditableCell = ({ initialValue }: { initialValue: any }) => {
It would also be a good idea to avoid any, which defeats the purpose of using TypeScript - wherever you have an any, identify the actual type that'll be used there, and use that type instead.

Invalid custom hook call

I'm just a react beginner. I'm trying to create a custom hook, which will be triggered once an onClick event is triggered. By what I see, I need to use the useRef hook, to take into account if the component is rendered by first time, or if it's being re-rendered.
My code approach is the next:
const Clear = (value) => {
const useClearHook = () => {
const stateRef = useRef(value.value.state);
console.log(stateRef);
useEffect(() => {
console.log("useEffect: ");
stateRef.current = value.value.state;
stateRef.current.result = [""];
stateRef.current.secondNumber = [""];
stateRef.current.mathOp = "";
console.log(stateRef.current);
value.value.setState({
...stateRef.current,
result: value.value.state.result,
secondNumber: value.value.state.secondNumber,
mathOp: value.value.state.mathOp,
});
}, [stateRef.current]);
console.log(value.value.state);
};
return <button onClick={useClearHook}>Clear</button>;
};
Any suggestion? Maybe I might not call ...stateRef.current in setState. I'm not sure about my mistake.
Any help will be appreciated.
Thanks!
Your problem is useClearHook is not a component (the component always goes with the first capitalized letter like UseClearHook), so that's why when you call useRef in a non-component, it will throw that error. Similarly, for useEffect, you need to put it under a proper component.
The way you're using state is also not correct, you need to call useState instead
Here is a possible fix for you
const Clear = (value) => {
const [clearState, setClearState] = useState()
const useClearHook = () => {
setClearState((prevState) => ({
...prevState,
result: [""],
secondNumber: [""],
mathOp: "",
}));
};
return <button onClick={useClearHook}>Clear</button>;
};
If your states on the upper component (outside of Clear). You can try this way too
const Clear = ({value, setValue}) => {
const useClearHook = () => {
setValue((prevState) => ({
...prevState,
result: [""],
secondNumber: [""],
mathOp: "",
}));
};
return <button onClick={useClearHook}>Clear</button>;
};
Here is how we pass it
<Clear value={value} setValue={setValue} />
The declaration for setValue and value can be like this in the upper component
const [value, setValue] = useState()

Proper Way of Searching in React with debounce

I have a problem searching using debounce in lodash. I'm using React, Material Ui and formik.
It says TypeError: Expected a function in
const debounceLoadData = useCallback( debounce(dispatch(getPersons(10, 1, filter)), 1000), [] );
const [filter, setFilter] = useState("");
const debounceLoadData = useCallback(
debounce(dispatch(getPersons(10, 1, filter)), 1000),
[]
);
const onSearchVoter = (value) => {
setFilter(value);
debounceLoadData(value);
};
<Autocomplete
value={values.voter_id}
options={persons ? persons : []}
getOptionSelected={(option, value) => option === value}
getOptionLabel={(person) =>
person
? [person?.fname, person?.mname, person?.lname].filter(Boolean).join(" ")
: ""
}
onChange={(e, value) => {
setFieldValue("voter_id", value ? value : "");
}}
onInputChange={async (event, value) => {
onSearchVoter(value);
}}
renderInput={(params) => (
<TextField
{...params}
name="voter_id"
label="Voter"
variant="outlined"
onBlur={handleBlur}
helperText={touched.voter_id ? errors.voter_id : ""}
error={touched.voter_id && Boolean(errors.voter_id)}
fullWidth
/>
)}
/>;
The _.debounce() function expects another function, and not the result of calling dispatch. You should wrap the dispatch call in an arrow function, and pass the filter value via the parameter value, and not as a dependency. In addition, add getPerson and dispatch as dependencies to the useCallback.
Note: the getPerson function should also be memoized (via useMemo or useCallback).
const [filter, setFilter] = useState('');
const debounceLoadData = useCallback(
debounce(value => dispatch(getPersons(10, 1, value)), 1000),
[getPersons, dispatch]
);
I do not find useCallback useful in debounce case. Instead use useMemo to create a fixed instance for dispatch (with fixed/variable input arguments).
const debouceLoadData = useMemo(() => {
return debounce(f => {
dispatch(getPersons(10, 1, f))
}, 1000)
}, [dispatch])
Now you can even do debounceLoadData(f) if you want to.
NOTE: the reason why useCallback doesn't work is that, practically you want to setState(v), the v is a variable. And also you don't want to invoke dispatch or getPersons when you are constructing the debounce. You only want to construct the instance, such as () => {} which is what useMemo covers.
This is a subtlety. But you can try useCallback first.

Lodash debounce with React Input

I'm trying to add debouncing with lodash to a search function, called from an input onChange event. The code below generates a type error 'function is expected', which I understand because lodash is expecting a function. What is the right way to do this and can it be done all inline? I have tried nearly every example thus far on SO to no avail.
search(e){
let str = e.target.value;
debounce(this.props.relay.setVariables({ query: str }), 500);
},
With a functional react component try using useCallback. useCallback memoizes your debounce function so it doesn't get recreated again and again when the component rerenders. Without useCallback the debounce function will not sync with the next key stroke.
`
import {useCallback} from 'react';
import _debounce from 'lodash/debounce';
import axios from 'axios';
function Input() {
const [value, setValue] = useState('');
const debounceFn = useCallback(_debounce(handleDebounceFn, 1000), []);
function handleDebounceFn(inputValue) {
axios.post('/endpoint', {
value: inputValue,
}).then((res) => {
console.log(res.data);
});
}
function handleChange (event) {
setValue(event.target.value);
debounceFn(event.target.value);
};
return <input value={value} onChange={handleChange} />
}
`
The debounce function can be passed inline in the JSX or set directly as a class method as seen here:
search: _.debounce(function(e) {
console.log('Debounced Event:', e);
}, 1000)
Fiddle: https://jsfiddle.net/woodenconsulting/69z2wepo/36453/
If you're using es2015+ you can define your debounce method directly, in your constructor or in a lifecycle method like componentWillMount.
Examples:
class DebounceSamples extends React.Component {
constructor(props) {
super(props);
// Method defined in constructor, alternatively could be in another lifecycle method
// like componentWillMount
this.search = _.debounce(e => {
console.log('Debounced Event:', e);
}, 1000);
}
// Define the method directly in your class
search = _.debounce((e) => {
console.log('Debounced Event:', e);
}, 1000)
}
This is how I had to do it after googling the whole day.
const MyComponent = (props) => {
const [reload, setReload] = useState(false);
useEffect(() => {
if(reload) { /* Call API here */ }
}, [reload]);
const callApi = () => { setReload(true) }; // You might be able to call API directly here, I haven't tried
const [debouncedCallApi] = useState(() => _.debounce(callApi, 1000));
function handleChange() {
debouncedCallApi();
}
return (<>
<input onChange={handleChange} />
</>);
}
That's not so easy question
On one hand to just work around error you are getting, you need to wrap up you setVariables in the function:
search(e){
let str = e.target.value;
_.debounce(() => this.props.relay.setVariables({ query: str }), 500);
}
On another hand, I belive debouncing logic has to be incapsulated inside Relay.
A lot of the answers here I found to be overly complicated or just inaccurate (i.e. not actually debouncing). Here's a straightforward solution with a check:
const [count, setCount] = useState(0); // simple check debounce is working
const handleChangeWithDebounce = _.debounce(async (e) => {
if (e.target.value && e.target.value.length > 4) {
// TODO: make API call here
setCount(count + 1);
console.log('the current count:', count)
}
}, 1000);
<input onChange={handleChangeWithDebounce}></input>
Improving on this answer: https://stackoverflow.com/a/67941248/2390312
Using useCallback and debounce is known to cause an eslint exhaustive deps warning.
Here's how to do it with functional components and useMemo
import { useMemo } from 'react';
import { debounce } from 'lodash';
import axios from 'axios';
function Input() {
const [value, setValue] = useState('');
const debounceFn = useMemo(() => debounce(handleDebounceFn, 1000), []);
function handleDebounceFn(inputValue) {
axios.post('/endpoint', {
value: inputValue,
}).then((res) => {
console.log(res.data);
});
}
function handleChange (event) {
setValue(event.target.value);
debounceFn(event.target.value);
};
return <input value={value} onChange={handleChange} />
}
We are using useMemo to return a memoized value, where this value is the function returned by debounce
Some answers are neglecting that if you want to use something like e.target.value from the event object (e), the original event values will be null when you pass it through your debounce function.
See this error message:
Warning: This synthetic event is reused for performance reasons. If you're seeing this, you're accessing the property nativeEvent on a released/nullified synthetic event. This is set to null. If you must keep the original synthetic event around, use event.persist().
As the message says, you have to include e.persist() in your event function. For example:
const onScroll={(e) => {
debounceFn(e);
e.persist();
}}
Then of course, your debounceFn needs to be scoped outside of the return statement in order to utilize React.useCallback(), which is necessary. My debounceFn looks like this:
const debounceFn = React.useCallback(
_.debounce((e) =>
calculatePagination(e),
500, {
trailing: true,
}
),
[]
);
#Aximili
const [debouncedCallApi] = useState(() => _.debounce(callApi, 1000));
looks strange :) I prefare solutions with useCallback:
const [searchFor, setSearchFor] = useState('');
const changeSearchFor = debounce(setSearchFor, 1000);
const handleChange = useCallback(changeSearchFor, []);
for your case, it should be:
search = _.debounce((e){
let str = e.target.value;
this.props.relay.setVariables({ query: str });
}, 500),
class MyComp extends Component {
debounceSave;
constructor(props) {
super(props);
}
this.debounceSave = debounce(this.save.bind(this), 2000, { leading: false, trailing: true });
}
save() is the function to be called
debounceSave() is the function you actually call (multiple times).
This worked for me:
handleChange(event) {
event.persist();
const handleChangeDebounce = _.debounce((e) => {
if (e.target.value) {
// do something
}
}, 1000);
handleChangeDebounce(event);
}
This is the correct FC approach
#
Aximili answers triggers only one time
import { SyntheticEvent } from "react"
export type WithOnChange<T = string> = {
onChange: (value: T) => void
}
export type WithValue<T = string> = {
value: T
}
// WithValue & WithOnChange
export type VandC<A = string> = WithValue<A> & WithOnChange<A>
export const inputValue = (e: SyntheticEvent<HTMLElement & { value: string }>): string => (e.target as HTMLElement & { value: string }).value
const MyComponent: FC<VandC<string>> = ({ onChange, value }) => {
const [reload, setReload] = useState(false)
const [state, setstate] = useState(value)
useEffect(() => {
if (reload) {
console.log('called api ')
onChange(state)
setReload(false)
}
}, [reload])
const callApi = () => {
setReload(true)
} // You might be able to call API directly here, I haven't tried
const [debouncedCallApi] = useState(() => _.debounce(callApi, 1000))
function handleChange(x:string) {
setstate(x)
debouncedCallApi()
}
return (<>
<input
value={state} onChange={_.flow(inputValue, handleChange)} />
</>)
}
const delayedHandleChange = debounce(eventData => someApiFunction(eventData), 500);
const handleChange = (e) => {
let eventData = { id: e.id, target: e.target };
delayedHandleChange(eventData);
}

Resources