Best practice interface for custom hooks with function arguments - reactjs

I'm creating a custom hook to detect clicks inside/outside a given HTMLElement.
Since the hook accepts a function as an argument, it seems like either the input needs to be wrapped in a useCallback or stored inside the hook with useRef to prevent useEffect from triggering repeatedly.
Are both of the following approaches functionally the same?
Approach One (preferred)
// CALLER
useClickInsideOutside({
htmlElement: htmlRef.current,
onClickOutside: () => {
// Do something via anonymous function
},
});
// HOOK
const useClickInsideOutside = ({
htmlElement,
onClickOutside,
}) => {
const onClickOutsideRef = useRef(onClickOutside);
onClickOutsideRef.current = onClickOutside;
useEffect(() => {
function handleClick(event) {
if (htmlElement && !htmlElement.contains(event.target)) {
onClickOutsideRef.current && onClickOutsideRef.current();
}
}
document.addEventListener(MOUSE_DOWN, handleClick);
return () => { document.removeEventListener(MOUSE_DOWN, handleClick); };
}, [htmlElement]);
}
Approach Two
// CALLER
const onClickOutside = useCallback(() => {
// Do something via memoized callback
}, []);
useClickInsideOutside({
htmlElement: htmlRef.current,
onClickOutside,
});
// HOOK
const useClickInsideOutside = ({
htmlElement,
onClickOutside,
}) => {
useEffect(() => {
function handleClick(event) {
if (htmlElement && !htmlElement.contains(event.target)) {
onClickOutside();
}
}
document.addEventListener(MOUSE_DOWN, handleClick);
return () => { document.removeEventListener(MOUSE_DOWN, handleClick); };
}, [htmlElement, onClickOutside]);
}
Does the first one (which I prefer, because it seems to make the hook easier to use/rely on fewer assumptions) work as I imagine? Or might useEffect suffer from enclosing stale function references inside handleClick?

useCallback is the right approach for this. However, I think I'd design it in a way where I could abstract the memo-ing away from the consumer:
/**
* #param {MutableRefObject<HTMLElement>} ref
* #param {Function} onClickOutside
*/
const useClickInsideOutside = (ref, onClickOutside) => {
const { current: htmlElement } = ref;
const onClick = useCallback((e) => {
if (htmlElement && !htmlElement.contains(e.target)) {
onClickOutside();
}
}, [htmlElement, onClickOutside])
useEffect(() => {
document.addEventListener(MOUSE_DOWN, onClick);
return () => document.removeEventListener(MOUSE_DOWN, onClick);;
}, [onClick]);
}
And since I already touched on design, I'd try to make the design resemble similar API functions where 2 arguments are [in]directly related. I'd end up looking like this:
// Consumer component
const ref = useRef()
useClickOutside(ref, () => {
// do stuff in here
})

Related

How to correctly get mouse events in react without re-rendering the parent component

I am trying to make a hook that returns the clientX and clientY values when the mouse moves on the screen. My hook looks like this -
useMouseMove hook
const useMouseMove = () => {
const [mouseData, setMouseData] = useState<[number, number]>([0, 0])
useEffect(() => {
const handleMouse = (e: MouseEvent) => {
setMouseData([e.clientX, e.clientY])
}
document.addEventListener("mousemove", handleMouse)
return () => {
document.removeEventListener("mousemove", handleMouse)
}
}, [])
return mouseData
}
And I'm using it in another component like so,
Usage in component
const SomeComponent = () => {
const mouseData = useMouseMoveLocation()
console.log("Rendered") // I want this to be rendered only once
useEffect(() => {
// I need to use the mouseData values here
console.log({ mouseData })
}, [mouseData])
return <>{/* Some child components */}</>
}
I need to use the mouseData values from the useMouseMove hook in the parent component (named SomeComponent in the above example) without re-rendering the entire component every time the mouse moves across the screen. Is there a correct way to do this to optimise for performance?
If you're not going to be rendering this component, then you can't use a useEffect. useEffect's only get run if your component renders. I think you'll need to run whatever code you have in mind in the mousemove callback:
const useMouseMove = (onMouseMove) => {
useEffect(() => {
document.addEventListener("mousemove", onMouseMove)
return () => {
document.removeEventListener("mousemove", onMouseMove)
}
}, [onMouseMove])
}
const SomeComponent = () => {
useMouseMove(e => {
// do something with e.clientX and e.clientY
});
}

React Hooks How to get to componentWillUnmount

Hello I'm trying to pass the following code to reacthooks:
import { disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
class SomeComponent extends React.Component {
// 2. Initialise your ref and targetElement here
targetRef = React.createRef();
targetElement = null;
componentDidMount() {
// 3. Get a target element that you want to persist scrolling for (such as a modal/lightbox/flyout/nav).
// Specifically, the target element is the one we would like to allow scroll on (NOT a parent of that element).
// This is also the element to apply the CSS '-webkit-overflow-scrolling: touch;' if desired.
this.targetElement = this.targetRef.current;
}
showTargetElement = () => {
// ... some logic to show target element
// 4. Disable body scroll
disableBodyScroll(this.targetElement);
};
hideTargetElement = () => {
// ... some logic to hide target element
// 5. Re-enable body scroll
enableBodyScroll(this.targetElement);
}
componentWillUnmount() {
// 5. Useful if we have called disableBodyScroll for multiple target elements,
// and we just want a kill-switch to undo all that.
// OR useful for if the `hideTargetElement()` function got circumvented eg. visitor
// clicks a link which takes him/her to a different page within the app.
clearAllBodyScrollLocks();
}
render() {
return (
// 6. Pass your ref with the reference to the targetElement to SomeOtherComponent
<SomeOtherComponent ref={this.targetRef}>
some JSX to go here
</SomeOtherComponent>
);
}
}
And then I did the following with hooks:
const [modalIsOpen, setIsOpen] = useState(false);
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
const targetRef = useRef();
const showTargetElement = () => {
disableBodyScroll(targetRef);
};
const hideTargetElement = () => {
enableBodyScroll(targetRef);
};
useEffect(() => {
if (modalIsOpen === true) {
showTargetElement();
} else {
hideTargetElement();
}
}, [modalIsOpen]);
I don't know if I did it correctly with useRef and useEffect, but it worked, but I can't imagine how I'm going to get to my componentWillUnmount to call mine:
clearAllBodyScrollLocks ();
The basic equivalents for componentDidMount and componentWillUnmount in React Hooks are:
//componentDidMount
useEffect(() => {
doSomethingOnMount();
}, [])
//componentWillUnmount
useEffect(() => {
return () => {
doSomethingOnUnmount();
}
}, [])
These can also be combined into one useEffect:
useEffect(() => {
doSomethingOnMount();
return () => {
doSomethingOnUnmount();
}
}, [])
This process is called effect clean up, you can read more from the documentation.

Is useRef Hook a must to set and clear intervals in React?

I'm currently understanding the useRef hook and its usage. Accessing the DOM is a pretty straight forward use case which I understood. The second use case is that a ref behaves like an instance field in class components. And the react docs provide an example of setting and clearing a time interval from a click handler. I want to know, if cancelling the time interval from a click handler is not required, can we set and clear intervals with local variables declared within useEffect like below? Or is using a ref as mentioned in the docs always the approach to be taken?
useEffect(() => {
const id = setInterval(() => {
// ...
});
return () => {
clearInterval(id);
};
})
As stated at the docs you shared;
If we just wanted to set an interval, we wouldn’t need the ref (id could be local to the effect).
useEffect(() => {
const id = setInterval(() => {
setCounter(prev => prev + 1);
}, 1000);
return () => {
clearInterval(id);
};
});
but it’s useful if we want to clear the interval from an event handler:
// ...
function handleCancelClick() {
clearInterval(intervalRef.current);
}
// ...
I think the example is just for demonstrating how useRef works, though I personal cannot find many use case for useRef except in <input ref={inputEl} /> where inputEl is defined with useRef. For if you want to define something like an component instance variable, why not use useState or useMemo? I want to learn that too actually (Why using useRef in this react example? just for concept demostration?)
As for the react doc example https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables:
function Timer() {
const intervalRef = useRef();
useEffect(() => {
const id = setInterval(() => {
// ...
});
intervalRef.current = id;
return () => {
clearInterval(intervalRef.current);
};
});
// ...
function handleCancelClick() {
clearInterval(intervalRef.current);
}
// ...
}
I tried and can achieve the same without useRef as below:
function Timer() {
const interval = null;
useEffect(() => {
const id = setInterval(() => {
// ...
});
interval = id;
return () => {
clearInterval(interval);
};
});
// ...
function handleCancelClick() {
clearInterval(interval);
}
// ...
}

How remember that a React hooks component is unmounted, so can avoid state changes?

With old class based React.js code, I can do like this, to remember that a component has been unmounted:
componentWillUnmount: function() {
this.isGone = true;
},
loadUsers: function() {
Server.loadUsers(..., (response) => {
if (this.isGone) return;
...
});
}
How can one do the same thing, with hooks based components?
Here's a sample hooks function component, where I'm unsure about how to remember that it's been unmounted, so I can return before calling any setSomeState:
export const GroupMembers = React.createFactory(function(props) {
const [membersOrNull, setMembers] = React.useState(null);
let isGone = false; // ? (se [222] below
React.useEffect(() => {
Server.listGroupMembers(someGroupId, (response) => {
if (/* is unmounted ?? */) return;
setMembers(response.members);
});
return () => { /* how remember is unmounted?
would isGone = true; work? */ };
}, []);
...
return ..., Button({ title: "Remove all members", onClick: () => {
Server.removeAllMembers(someGroupId, () => {
if (/* is unmounted ?? */) return;
setMembers([]);
});
}});
I suppose I cannot use const [isGone, setGone] = useState(false) because I shouldn't try to access the state (read isGone) after has been unmounted. And if I [222] add a local let isGone = false inside the function, it seems to me that various callbacks created inside the function, will refer to different "instances" of this local variable, depending on in which different GroupMembers(..) invokation the different callbacks were created? or am I mistaken and this works? — Maybe I could create an outer wrapper function with a local let isGone = false, however, this adds another wrapping function and indentation :- /
Since you are using the isGone flag outside of the useEffect method too, you can make use of useRef to store variables like
export const GroupMembers = React.createFactory(function(props) {
const [membersOrNull, setMembers] = React.useState(null);
const isGone = useRef(false); // ? (se [222] below
React.useEffect(() => {
Server.listGroupMembers(someGroupId, (response) => {
if (isGone.current) return;
setMembers(response.members);
});
return () => {
isGone.current = true;
};
}, []);
...
return ..., Button({ title: "Remove all members", onClick: () => {
Server.removeAllMembers(someGroupId, () => {
if (isGone.current) return;
setMembers([]);
});
}});
PS: A better way to handle such things is to cancel the request when
you are leaving the page instead of waiting for the response only to
neglect it.
just use local variable accessed through closure
React.useEffect(() => {
let isActual = true;
Server.listGroupMembers(someGroupId, (response) => {
if (!isActual) return;
setMembers(response.members);
});
return () => {isActual = false;};
}, []);
in this case flag would be updated on unmounting only. but in general case(with some unempty dependencies for useEffect) it will use as well. So in case of sequential renderings you could be sure you never process older request.
PS most universal way is cancelling request when it's possible.

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