React Hooks useEffect to call a prop callback from useCallback - javascript

I'm trying to make a general-purpose infinite scroller with React Hooks (and the ResearchGate React Intersection Observer). The idea is that a parent will pass down a mapped JSX array of data and a callback that will asynchronously get more data for that array, and when the intersection observer fires because you've scrolled down enough to reveal the loading icon, the callback gets called and more data is loaded.
It works well enough, except one thing: esLint tells me that because I'm calling the getMore function (from the props) inside a useEffect, it must be a dependency of that effect. But because in the parent's callback I'm accessing its data array's length, that array must be a dependency of useCallback there. And then that callback modifies the array.
TL;DR: I'm getting race conditions that cause the async callback to trigger multiple times when it shouldn't, because the callback function reference is changing and then being passed down to the thing that's calling it.
Here's some code to clarify.
The callback in the parent:
const loadData = useCallback(async () => {
if (hasMore) {
const startAmount = posts.length;
for (let i = 0; i < 20; ++i) {
posts.push(`I am post number ${i + startAmount}.`);
await delay(100);
}
setPosts([...posts]);
setHasMore(posts.length < 100);
}
}, [posts, hasMore]);
posts and hasMore are just state variables, with posts being passed down as the data array in props to the child. That function is being passed to the child in props, which has this (getMore is the destructured prop for the callback, isLoading is just a boolean state variable):
useEffect(() => {
if (isLoading) {
(async () => {
await getMore();
setIsLoading(false);
})();
}
}, [isLoading, getMore]);
I'm setting isLoading to true to trigger the effect; but it's also triggering because getMore's reference changes when the parent loads data and the function memoizes. That shouldn't happen. I could just disable esLint for that line, but I assume there's a better solution, and I'd like to know what it is.

Solution: don't use useEffect at all. Just call the loading function directly from the observer and have that set isLoading and call the callback rather than having isLoading trigger the callback.
const loadData = async (observerEntry) => {
if (observerEntry.isIntersecting && !disabled && !isLoading) {
setIsLoading(true);
await getMore();
setIsLoading(false);
}
};

Related

With flushSync() inside useEffect do we do the same as using useLayoutEffect?

https://reactjs.org/docs/react-dom.html#flushsync
Force React to flush any updates inside the provided callback
synchronously. This ensures that the DOM is updated immediately.
// Force this state update to be synchronous.
flushSync(() => {
setCount(count + 1);
});
// By this point, DOM is updated.
Knowing that, it is the same as using useLayoutEffect, or do I misunderstand flushSync()?
const App = () => {
const [name, setName] = React.useState("Leonardo");
React.useEffect(() => {
ReactDOM.flushSync(() => {
for (let index = 1; index <= 100000; index++) { // for simulate blocking
console.log(index);
}
setName("Jose");
});
});
return (
<div>
<h1>Hello {name}</h1>
</div>
);
};
¿it is the same that this?
React.useLayoutEffect(() => {
for (let index = 1; index <= 100000; index++) {
console.log(index);
}
setName("Jose");
});
useLayoutEffect is useful for things that need to happen before paint your dom or when your code is causing flickering. it's already synchronous and executed always before every useEffect hook in your code.
flushSync is used to convert the setState into synchronous. in 99% of cases you will use flushSync inside a handler like a form submit handler, outside of useEffect to execute an imperative action
function handleSubmit(values) {
flushSync(() => {
setForm(values);
});
}
Be aware that flushSync force a re-rendering, so use it carefully
The common use case of flushSync is update the DOM after settings the state immediately. example scroll to the new added element in the list
flushSync(() => {
setElements((elements) => [
...elements,
{
id: 'random',
},
]);
});
// scroll to element here
Check this example https://codesandbox.io/s/react-18-batching-updates-flushsync-forked-vlrbq8. you can delete flushSync and see the diff
flushSync is used to force React to flush a state update and when you try to put it inside useEffect it won't affect when useEffect is invoked, it will always be after the changes have been reflected on the browser, whereas useLayoutEffect is invoked before and this is the main difference between them.
so flushSync is not a function that is supposed to be executed inside useEffect you will even get this warning
Warning: flushSync was called from inside a lifecycle method. React cannot flush when React is already rendered. Consider moving this call to a scheduler task or microtask.
They are not same. Your code might give the same performance, functional, and results, yet they are different in nature.
flushSync is a low-level API that flushes updates to the React DOM immediately, bypassing the normal scheduling mechanism. It should be used sparingly and only when necessary, as it can lead to poor performance and is more prone to bugs and inconsistencies. On the other hand, useLayoutEffect is a higher-level hook that schedules a DOM update to happen synchronously after all other updates have been processed. It is generally the preferred way to ensure that updates are synchronized with the layout of the page.
I did this code to see if I understood everything, please correct me:
function App2() {
const [c, setC] = React.useState(0);
const inc1 = () => {
/*setC is asynchronous (withoutFlushSync), so it continues the
code downwards and therefore from the DOM it
brings us the value without increment in 1*/
setC((c) => c + 1);
console.log(document.getElementById("myId").innerText); // old value from DOM
console.log(c); // However below log will still point to old value ***BECAUSE OF CLOSURE***
};
const inc2 = () => {
/*waits until the DOM is modified with the new state (in the first click
c = 1)*/
ReactDOM.flushSync(() => { // Wait
setC((c) => c + 1);
});
/* brings the c = 1 of the DOM because it waited until the DOM
was modified with the new state incremented by one. */
console.log(document.getElementById("myId").innerText); // new value from DOM
console.log(c); // However below log will still point to old value ***BECAUSE OF CLOSURE***
};
return (
<div className="App">
Count: <div id="myId">{c}</div>
<button onClick={inc1}>without flushSync</button>
<button onClick={inc2}>with flushSync</button>
</div>
);
}

Related to weird behaviour of react useState and useEffect

import {useEffect,useState} from 'react';
export default function App() {
const [count,setCount]=useState(0);
const [flag,setFlag]=useState(false);
function increment(){
setCount(prevState=>{
if(flag)
return prevState
return prevState+1;
});
}
useEffect(function(){
increment();
setFlag(true);
increment();
},[]);
return (
<div className="App">
{count}
</div>
);
}
Was playing around with effects and states in reatct functional component, I expected the code to output "1" but it's giving the output as "2", Why is it happening and How can I make it print 1 ?
Once you call setFlag, React will update the returned value of your useState call [flag,_] = useState() on the next render.
Your setFlag(true) call schedules a re-render, it doesn't immediately update values in your function.
Your flag is a boolean const after all -- it can't be any value but one value in that function call.
How to solve it gets interesting; you could put the the flag inside of a single state object i.e. useState({count: 0, flag: false})
But more likely, this is an academic problem. A count increment sounds like something that would trigger on a user interaction like a click, and so long as one function doesn't call increment() multiple times (this sounds unusual), the re-render will happen in time to update your flag state.
For performance reasons, React defers useState hook updates until function completes its execution, i.e. run all statements in the function body and then update the component state, so React delays the update process until a later time.
Thus, when increment function execution is completed, React updates the state of count. But for setFlag method, the execution environment is a context of useEffect hook's callback, so here React's still waiting for a completion of useEffect's callback function. Therefore, inside the callback of useEffect the value of flag is still false.
Then you again called your increment function and when this function finished its execution, your count again was incremented by 1.
So, in your case, the key factor is the way of deferring state updates until function execution by React.
Think of setState() as a request rather than an immediate command to update the component. For better perceived performance, React may delay it, and then update several components in a single pass. React does not guarantee that the state changes are applied immediately.
setState() does not always immediately update the component. It may batch or defer the update until later. This makes reading this.state right after calling setState() a potential pitfall. Instead, use componentDidUpdate or a setState callback (setState(updater, callback)), either of which are guaranteed to fire after the update has been applied. If you need to set the state based on the previous state, read about the updater argument below.
React Component: setState()
For more information, you can also read about Batch updates in React (especially in React 18) or Reactive programming (this is not React), where the main idea is real-time or timely updates.
For a better understanding I would think it of as replacing the invocation directly with setter and we know how the state batching works so ...
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
useEffect(function () {
// increment(); becomes below
setCount((prevState) => {
if (flag) return prevState;
return prevState + 1;
});
// queued update count returns
// count => count + 1 0 0 + 1 = 1
setFlag(true);
//set flag=true in next render
// increment(); becomes below
setCount((prevState) => {
if (flag) return prevState;
return prevState + 1;
});
// so flag is still false here and count is 1
// queued update count returns
// count => count + 1 1 1 + 1 = 2
// done and count for next render is 2 and flag will be false
}, []);
return <div className="App">{count}</div>;
A better explaination in Docs - Queueing state updates and state as snapshot
State updates are "batched". See the other answers for an explanation. Here's a workaround using useRef - since a ref can be updated during this render, you can use it like a "normal" variable.
const { useState, useRef, useEffect } = React;
function App() {
const [count, setCount] = useState(0);
const flag = useRef(false);
function increment() {
setCount(prevState => {
if (flag.current)
return prevState;
return prevState + 1;
});
}
useEffect(function() {
increment();
flag.current = true;
increment();
}, []);
return <div className="App">{count}</div>;
}
ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>

How to access the new value after using useState when outside the body of the function component

Consider the following code
const App = () => {
const [errorMsgs, setErrorMsgs] = useState([])
const onButtonPressed = () => {
fetchErrors(setErrorMsgs)
if (!errorMsgs.length) {
// Procced and do somethings
}
}
}
function fetchErrors(setErrorMsgs) {
// if some condition is true:
setErrorMsgs(errorMsgs => [...errorMsgs, "some error"])
// if some other condition is true:
setErrorMsgs(errorMsgs => [...errorMsgs, "some other error"])
}
As far as I understand, when using setErrorMsgs, it doesn't immediately update the state, and this cannot be solved by using await or .then() because setErrorMsgs doesn't return a promise, therefore, the condition will never be true in this sample.
So what should I do so the condition runs after the state updates? (I don't want the condition to run every time the errorMsgs changes.)
Edit: To clarify:
The problem, when I call fetchErrors function, it checks some conditions and might append one, or more errors to errorMsgs. When I check to see if errorMsgs is an empty array, the condition will be true even if the fetchErrors adds anything to the array.
You can't assume that when you change state the variable had it's value changed, but react have a correct way to handle it, by using useEffect, so
const [state, setState] = useState(false)
const onButtonPressed = () => {
setState(true)
}
useEffect(() => {
// here you can do some stuff
},[state])
You can redesign your fetchErrors function, to return the value from the condition, instead of running setter function, then you can do what #RobinZigmond has suggested:
const newErrors = fetchErrors();
setState(newErrors);
if (!newErrors.length) {...}
this way you know for sure what your next state will be like.

Defining MainButton.onClick() for Telegram Webapp correcly

I am trying to define window.Telegram.WebApp.MainButton.onClick() functionality to use the most recent values.
Lets see through code:
// selectedRegions is an array of strings
const [selectedRegions, setSelectedRegions] = React.useState([""]);
React.useEffect(() => {
window.Telegram?.WebApp.MainButton.onClick(() => {
// window.Telegram.WebApp.sendData(selectedRegions);
alert("main button clicked");
});
}, [selectedRegions]);
Now as I update the selectedRegions, this useEffect is called for the number of times the state changes which also updates the MainButton.onClick() functionality. Then at the end, when the MainButton is pressed, in this case, alert() is shown the number of times the state was updated, eg, if the selectedRegions contain 3 values, the alert will be shown 3 times.
What I want is to somehow define this onClick() once or the functionality executed only once, so that I can send the selectedRegions only once with the most recent values back to the bot.
UPDATE 1: After looking into the function onEvent() in telegram-web-app.js file,
function onEvent(eventType, callback) {
if (eventHandlers[eventType] === undefined) {
eventHandlers[eventType] = [];
}
var index = eventHandlers[eventType].indexOf(callback);
if (index === -1) {
eventHandlers[eventType].push(callback);
}
};
It seems to my understanding that if the callback is present in eventHandlers["webView:mainButtonClicked"], then it will not do anything, otherwise just push it into the array.
What I fail to understand is that somehow the callbacks are different, that's why they get appended to the array, justifying the multiple calls to it.
However, I am trying to use MainButton.offClick() to remove the from eventHandlers array, have not succeeded yet. If anyone has done so, it would be highly appreciated.
I have faced exactly same issue. I had some buttons that updates state and on main button click I wanted to send that state, but as you mentioned telegram stores all callbacks in array and when you call sendData function, web app gets closed and only first callback is executed. If you tried to remove function with offClick, I think that didn't worked, because callbacks are compared by reference, but function in component was recreated by react when component is re-rendered, so, that's why offClick failed to remove that function. In this case solution would be like this
const sendDataToTelegram = () => {
window.Telegram.WebApp.sendData(selectedRegions);
}
useEffect(() => {
window.Telegram.WebApp.onEvent('mainButtonClicked', sendDataToTelegram)
return () => {
window.Telegram.WebApp.offEvent('mainButtonClicked', sendDataToTelegram)
}
}, [sendDataToTelegram])
If you are not familiar with this syntax - return in useEffect is callback that is called before new useEffect is called again
Or you can solve this also with useCallback hook to recreate function only when selected region state is changed. Like this:
const sendDataToTelegram = useCallback(() => {
window.Telegram.WebApp.sendData(selectedRegions);
}, [selectedRegions])
useEffect(() => {
window.Telegram.WebApp.onEvent('mainButtonClicked', sendDataToTelegram)
return () => {
window.Telegram.WebApp.offEvent('mainButtonClicked', sendDataToTelegram)
}
}, [sendDataToTelegram])
I use Angular with typescript and I ran into this problem too. But I finally found a solution: for some reason if I pass a callback like this -
"offClick(() => this.myFunc())" the inner "indexOf" of the "offEvent" cannot find this callback and returns "-1".
So I ended up with:
setOnClick() {
let f = () => this.myFunc;
window.Telegram.WebApp.MainButton.onClick(f);
}
setOffClick() {
let f = () => this.myFunc;
window.Telegram.WebApp.MainButton.offClick(f);
}
myFunc() {
console.log('helloworld');
}
Hope this helps.

Is it bad to reapply all event listeners when state changes in React

I have a hook that lets me change the page in my React app, using the scroll wheel or the keyup event. At the end, I came to this solution, which works:
function useScrollingPage (pages) {
const [page, setPage] = useState(0);
const wheelHandler = (event) => {
// some logic
setPage(page + 1);
};
useEffect(
() => {
window.addEventListener(EVENTS.WHEEL, wheelHandler);
window.addEventListener(EVENTS.KEY_UP, keyHandler);
return () => {
window.removeEventListener(EVENTS.WHEEL, wheelHandler);
window.removeEventListener(EVENTS.KEY_UP, keyHandler);
};
},
[page],
);
return page;
}
But, while working on it, I ran into a few iterations.
I would call addEventListener outside the useEffect. This way, everything works, but the listener is never removed.
I would pass an empty array as the second argument of useEffect. That way, it only runs on component mount, but it doesn't change the page reference in wheelHandler, so it will always try to set the page to 1 (setPage(0 + 1))
Run useEffect every time the page changes. It works, but it doesn't feel right to remove and add all listeners every time the state changes.
Is it bad for performance, to add/remove event listeners every time the state changes? What would be the best solution for a problem like this?
According to the docs, when the listeners depends on state or props to subscribe you would want to make use of the above pattern, However in the above case you could do with the callback pattern of state updates and pass an empty array as the second argument of useEffect
function useScrollingPage (pages) {
const [page, setPage] = useState(0);
const wheelHandler = (event) => {
// some logic
setPage(page => page + 1);
};
useEffect(
() => {
window.addEventListener(EVENTS.WHEEL, wheelHandler);
window.addEventListener(EVENTS.KEY_UP, keyHandler);
return () => {
window.removeEventListener(EVENTS.WHEEL, wheelHandler);
window.removeEventListener(EVENTS.KEY_UP, keyHandler);
};
},
[],
);
return page;
}
However when you write the above logic, make sure that you aren't using state in the wheelHandler. If you are, then you would follow the pattern that works for you and is recommended.

Categories