I'm reading this article and I'm not sure I understand how the final hook works.
Here is the code:
const useAnimationFrame = (callback) => {
const requestRef = useRef();
const previousTimeRef = useRef();
const animate = (time) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []);
}
and used for example in this way:
const [count, setCount] = useState(0);
useAnimationFrame((deltaTime) => {
setCount((prevCount) => {
return prevCount + 1;
});
});
Ok, the goal is have a number value that is incremented every frame.
I can explain what is happens running this code:
the component create a local state with useState(0)
then the useAnimationFrame hook is called using this callback as parameter:
(deltaTime) => {
setCount((prevCount) => {
return prevCount + 1;
});
}
the function takes a number as input and increment ste state value of one each time it is called.
useAnimationFrame is a function that takes another function as a parameter (a callback). It creates two refs. At the first time it is executed (because of the []) it calls the useEffect. It saves in requestRef.current the timestamp the requestAnimationFrame returns. The requestRef.current calls the animate function that computes the delta time between the request animation frames (the previous and the current) and then call the callback with this value so it calls the setCount. Then it updates the current refs values and recall the requestAnimationFrame.
So the cycle should be:
component
> count = 0
useAnimationFrame <--------------+
> requestRef = ? |
> previousTimeRef = ? |
useEffect |
animate |
> deltaTime = delta#1 |
> count = 1 |
> previousTimeRef.current = time#1 |
> requestRef.current = time#2 -------+
> requestRef.current = timestamp#1
Am I wrong?
It might be helpful to track the function signatures of requestAnimationFrame and cancelAnimationFrame.
requestAnimationFrame takes a single argument, a callback function. The callback function itself receives a single timestamp argument (DOMHighResTimeStamp)
cancelAnimationFrame takes a single argument, the id of the requestAnimationFrame which you want to cancel.
So time in the animate callback function is the single argument received via the api, a DOMHighResTimeStamp similar to the one returned by performance.now(), indicating the point in time when requestAnimationFrame() starts to execute callback functions.
const animate = (time) => {
This is a check to see if the hook has already run 1x. If it has, update the parent React scope with the new time minus the previous time
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
callback(deltaTime);
}
Once the hook is confirmed as having run, save the DOMHighResTimeStamp for future calculations
previousTimeRef.current = time;
After this, it gets a bit interesting and I am not sure this is best approach. It may even be a bug. The code sets a new listener and updates the ref with the latest id, based on the result of a new invocation.
Just from reading the code, I am not sure the original listener ever gets cancelled. I suspect it is not.
/// this is an id
requestRef.current = requestAnimationFrame(animate);
I don't have access to a running version, but I would suggest removing the requestRef.current altogether and see if the clean-up happens as expected when the useEffect clean-up happens e.g.
useEffect(() => {
const id = requestAnimationFrame(animate);
return () => cancelAnimationFrame(id);
}, []);
This will also simplify the embedded refs as well to make reading a bit more clear.
Related
I'm trying to do automated card pull started with button, and stop it when croupierCount reach 17, using setInterval. Value of croupierCount changes (<div> displays this count) with this function below, but when I'm trying to reach this value inside function to stop interval, it's logged value is 0. Can you help me solve this?
const TheGame = () => {
const [croupierCount, setCroupierCount] = useState(0);
const [croupierHand, setCroupierHand] = useState([]);
const onStandHandler = () => { // triggered with button
const croupierInterval = async () => {
let card = await fetchCard(deck); // fetching new card with every iteration (works)
croupierHand.push(card); // pushes fetched card (works)
if (card[0].value === 'ACE') {
setCroupierCount((prevCount) => prevCount + 11);
}
else if (card[0].value === 'JACK' || card[0].value === 'QUEEN' || card[0].value === 'KING') {
setCroupierCount((prevCount) => prevCount + 10);
}
else {
setCroupierCount((prevCount) => prevCount + Number(card[0].value));
};
// croupierCount is changing (I'm displaying it in div)
console.log(croupierCount); // croupierCount = 0, I don't know why.
if(croupierCount > 17) {
clearInterval(startInterval);
};
}
const startInterval = setInterval(croupierInterval, 1000);
};
};
You seem to miss an important point: using const here...
const [croupierCount, setCroupierCount] = useState(0);
... makes croupierCount a constant value, regardless of how many times setCroupierCount is called. This variable cannot be updated directly: what you see as its update is actually changes in React internal state, represented by the same name when the component is rerendered - and render function is called again.
This immutability is both a blessing and a curse typical to hook-based functional components.
Here's what happens here:
when component is rendered, TheGame function is called first time. Its useState call initializes both value and the corresponding setter as a part of internal React state tied to this component's instance.
Those values are returned from useState function - and are stored in local variables (constants!) of TheGame function, croupierCount and setCroupierCount. What's important - and often missed - is that these particular variables are created anew each time TheGame function is called!
then onStandHandler function is created, having both aforementioned local variables available as part of its scope.
at some point, onStandHandler function is triggered (when user presses a button). It creates yet another function, croupierInterval, which should fetch data first, then update the state by calling setCroupierCount with result of this fetch.
There are two problems with this function, though.
First, all croupierInterval sees is values of current croupierCount and setCroupierCount variables. It cannot magically 'peek' into which values those variables will carry when rerender is triggered and TheGame function is executed next time - as those will be new variables actually!
But there's a bigger problem you seem to miss: setInterval doesn't play nicely with fetch (or any async action). Instead of waiting for the processing of that action, you just make JS trigger this function periodically.
Not only this messes up with an expected delay (slow down fetch so that it takes 10 seconds, then see what happens), but there's an actual bug here: as clearInterval(startInterval) doesn't stop processing all the parts of a function that follow await fetchCard(deck), in the worst case, your Croupier might go way above 17.
This is for 'why' part, but what's on 'how to fix'? There are several things worth trying here:
avoid using setInterval in functional components like a plague: there are often far better replacements. In this case in particular, you should've at least tied setting up calling croupierInterval to the previous call's completion
useEffect whenever you want something to modify your state indirectly as some kind of side-effect. Not only this makes your code easier to read and understand, but also lets you clear out the side effects of side effects (like timeouts/intervals set)
don't forget to handle human errors, too: what should happen if a user mistakenly double-clicks this button?
I'm posting my solution, if someone encounters a similar problem.
const isMount = useRef(false);
...
useEffect(() => {
if (isMount.current && croupierCount !== 0) {
if (croupierCount < 17) {
setTimeout(() => {
onStandHandler();
}, 1000);
}
}
else isMount.current = true;
}, [croupierCount]);
const onStandHandler = async () => {
let card = await fetchCard(deck, 1);
croupierHand.push(card);
if (card[0].value === 'ACE') {
setCroupierCount(prevCount => prevCount + 11);
}
else if (card[0].value === 'JACK' || card[0].value === 'QUEEN' || card[0].value === 'KING') {
setCroupierCount(prevCount => prevCount + 10);
}
else {
setCroupierCount(prevCount => prevCount + Number(card[0].value));
};
};
I am writing a useDebounce util hook.
function debounce(fn, delay) {
let timer = 0;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
}, delay);
};
}
function useDebounce(fn, delay) {
const ref = React.useRef(fn);
React.useLayoutEffect(() => {
ref.current = fn;
}, [fn]);
return useMemo(() => debounce(ref.current, delay), [delay]);
}
I use ref to store the callback and update it using useLayoutEffect so the consumers of the API don't need to memoize their own callback. And also I wanted to preemptively answer that I know how useMemo works and I know you can memo the callback i.e. fn passed in useDebounce from outside but I don't want that burden on the users of the API so I did it myself.
Here is a live demo: https://codesandbox.io/s/closure-bug-xcvyd?file=/src/App.js
Now the function I want to denounce is
const increment = () => {
console.log(count);
setCount(count + 1);
};
so I just passed it in to useDebounce but it seems like the function ended up with stale closure over count because it only updates count from 0 -> 1 and then after that no matter how many times you click on the button it doesn't update anymore.
Yes I know I can write setCount(c => c + 1); to work around this problem.
But what perplexed me is that, if I rewrite useMemo(() => debounce(ref.current, delay), [delay]); to return useMemo(() => debounce((...args) => ref.current(...args), delay), [ delay ]); then this problem is fixed automatically.
I cannot seem to understand how (...args) => ref.current(...args) is fixing the problem.
Let's look what is happening step by step.
You are placing fn inside ref.
You are updating ref with new value
You are passing fn to debounce inside useMemo and this is where the error is.
On next render, you are again updating ref, but memoized function don't use it at all. It remembers reference to very first passed fn and this will change only when user of your hook will change delay.
In fixed example, with arrow function this is what happens:
You are placing fn inside ref
You are updating ref with new value
You are memoizing function that closures ref and will look inside it on each call, so it will pick the freshest fn value from ref.
function useDebounce(fn, delay) {
// storing function into ref
const ref = React.useRef(fn);
// updating function after memoization, and on each render when function changed
React.useLayoutEffect(() => {
ref.current = fn;
}, [fn]);
return useMemo(function() {
// here you are referencing current `fn`
// The very first `fn` that was passed into hook
// ref don't play role here - you are passing `ref.current`
let fnToDebounce = ref.current
return debounce(fnToDebounce, delay)
}, [delay]);
}
function useDebounce(fn, delay) {
// storing function into ref
const ref = React.useRef(fn);
// updating function after memoization, and on each render when function changed
React.useLayoutEffect(() => {
ref.current = fn;
}, [fn]);
return useMemo(function() {
// Here you are closuring reference to `ref` and `fnToDebounce` have no idea what is inside it.
// When it would be called, it will get ref and only at that point will retrieve `ref.current`, that will be the latest `fn`
let fnToDebounce = (...args) => ref.current(...args);
return debounce(fnToDebounce, delay);
}, [delay]);
}
This will pass ref.current to debounce.
useMemo(() => debounce(ref.current, delay), [delay]);
It's equivalent to this:
useMemo(() => debounce(fn, delay), [delay]);
The memoized function will only be created the first time you call the hook. The closure will have the original increment which encloses the original count, but ref is not enclosed.
In this version, however, you pass a lambda function with ref enlosed.
return useMemo(() => debounce((...args) => ref.current(...args))
Each time useDebounce is called, you change the increment function to a new one with the current count enclosed. useLayoutEffect will update ref, which is also enclosed in the memoized/debounced function.
So in the second case you have a nested chain of closures, which ensures that the debounce function will always have access to the latest count.
useMemo -> debounce -> (lambda) -> ref -> current -> increment -> count
You could simplify the code by just using the useCallback hook instead of making your own. But you must pass an updater function to setCount, to avoid a stale count value.
const increment = React.useCallback(
debounce(() => setCount((n) => n + 1), delay),
[setCount, delay]
)
Code Sandbox demo of this
This is a standard useDebounce
function useDebounce(value, delay) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay] // Only re-call effect if value or delay changes
);
return debouncedValue;
}
I have a react component that performs a certain task at regular intervals of time after mounting. But I want to clear the interval once after a criterion is met. How can I achieve that?
My code
const [totalTime, setTotalTime] = React.useState(10000);
const foo = () => {
console.log("Here");
};
React.useEffect(() => {
const secondInterval = setInterval(() => {
if (totalTime > 0) setTotalTime(totalTime - 1000);
}, 1000);
return () => clearInterval(secondInterval);
});
React.useEffect(() => {
let originalInterval;
if (totalTime > 0)
originalInterval = setInterval(() => {
foo();
console.log(totalTime);
}, 5000);
return () => clearInterval(originalInterval);
}, []);
When I watch the console even after 10000ms It is still logging Here and also totalTime is always being 10000ms. I am not able to figure out what exactly is happening.
You may need to pass the older state as an argument to the setTotalTime updater function. You also may need to pass (another) state variable as a dependency to the useEffect hook so that the function is executed every time the state variable changes
React.useEffect(() => {
const secondInterval = setInterval(() => {
if (totalTime > 0) setTotalTime(totalTime => totalTime - 1000);
}, 1000);
return () => clearInterval(secondInterval);
}, [...]);
Depends on your criteria, and what you call a criteria, but you could just use another state and then useEffect on that another state:
const [criteriaIsMet,setCriteriaIsMet] = useState(false);
useEffect(() => { if(criteriaIsMet) {clearInterval(...)} },[criteriaIsMet])
And then somewhere when you do your actual "criteria logic" you just go ahead and do setCriteriaIsMet(true)
And how would you know Id of interval in above useEffect - again you could just create a special state that will store that id, and you could set it in your original useEffect
As for why your current code is not clearing the interval is because when you use useEffect with empty array as second argument, and return function in first function argument - that will be execute on component unmounting
And these are just one of numerous options you have actually :)
I have a search input in one of my page and I'm trying to make sure that it does not make 10 requests in one second if the user types too fast.
Seems like debouncing is the way to go. I've read multiple blog posts and SO questions. I'm trying to use debounce from lodash. I must be doing something wrong since what happens is that all my call are passed, just later.
Here is my component code:
const Partners = (props: any) => {
const [query, setQuery] = useState("");
const { partners, totalPages, currentPage } = props.partnersList;
useEffect(() => {
props.getPartners();
}, []);
useEffect(() => debounce(() => props.getPartners(query), 10000), [query]);
const handleQueryChange = (e: any) => {
setQuery(e.target.value);
};
const handlePageChange = (e: React.ChangeEvent<unknown>, value: number) => {
props.getPartners(query, value);
};
return (
<React.Fragment>
<Sidebar />
<MainContent className="iamSearchPartners">
<h1>Partners</h1>
<TextField
className="iamSearchPartners__search_input"
variant="outlined"
size="small"
onChange={handleQueryChange}
value={query}
/>
<PartnersList partners={partners} />
{totalPages > 1 && (
<Pagination
currentPage={currentPage || 1}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
)}{" "}
</MainContent>
</React.Fragment>
);
};
As you can see, I have a useEffect listening to changes to query.
debounce creates a debounced version of the function that passed as the argument. In this specific case, calling it in useEffect will create a new debounced version of the function on every render.
To mitigate this, the debounced version could be created outside of the render function, so it is not recreated on every render.
const myDebouncedFunction = debounce((handler, query) => handler(query), 10000);
const Partners = (props: any) => {
// omitted ...
useEffect(() => {
myDebouncedFunction(props.getPartners, query);
}, [query]);
// omitted ...
useMemo or putting it into a useState could work as well.
Another thing: useEffect only called the returned debounced version since it's an arrow function without braces, making it return the result of the evaluation. useEffect leverages the return of it's function to be some kind of "unsubscribe" handler, see React docs about "Effects with cleanup". So whenever query changed (after the second time), the effect was calling the function. The version above should be called on every query change.
Personally, I would try to handle the call of the debounced version of the function in the handleQueryChange instead of a useEffect, to make the "when should it happen" more clear.
I end up doing this manually honestly. Check out the differences between debouncing and throttling. I think you want to throttle the requests. If you debounce, nothing is going to happen until the timer ends.
If you want to wait 10 seconds as in your example, nothing should happen if the user types at least once every ten seconds, until 10 seconds after the last type. As opposed to throttling where a request would go out every 10 seconds.
This approach is kind of a hybrid because we are making sure the last one still goes out (as a debounce would), and throttling may not do, but we are still sending requests while the user is typing.
const timer = useRef<number>(0);
const lastEventSent = useRef(Date.now());
useEffect(() => {
if (Date.now() - lastEventSent.current < 500) {
// The event was fired too recently, but we still want
// to fire this event after the timeout if it is the last
// one (500ms in this case).
timer.current = setTimeout(() => {
lastEventSent.current = Date.now();
props.getPartners(query)
}, 500);
} else {
// An event hasn't been fired for 500ms, lets fire the event
props.getPartners(query)
lastEventSent.current = Date.now();
// And if the user was typing, there is probably a timer, lets clear that
clearTimeout(timer.current);
}
// Cleanup the timer if there was one and this effect is reloaded.
return () => clearTimeout(timer.current);
}, [query]);
As shown in the code below, I am using the useEffect hook to watch for changes to a percentage variable, then set a timer to increment that variable in 1 second. The callback will occur as soon as the page loads, kicking the process off.
The percentage variable is used to render a line chart that increments until a passed value is reached. (It basically shows a % pass rate, but on load the bars will move across the screen up to their %.)
Everything works fine, but the console flags the maximum update depth warning which points out it's trying to prevent an infinite loop when i'm using setState inside useEffect. I get the point here, but I know I'll never pass in a value above 100 and so there will never be an infinite loop. I also need the recursive functionality to have my charts appear to populate dynamically.
Even if I decided to ignore the warning, it's creating error in my testing if I mount the component.
Is there a way I can tell react that this won't be an infinite loop and get the error to go away? Or do I need a different approach?
const ReportProgressSummary = ({result, title}) => {
const [percent, setPercent] = useState(0);
let timer = null;
useEffect( () => {
let newPercent = percent + 1;
if (newPercent > result) {
clearTimeout(timer);
return;
}
timer = setTimeout(setPercent(newPercent), 1000);
}, [percent]);
return (
<ContainerStyled>
<ChartContainerStyled>
<ChartTitleStyled>{title}</ChartTitleStyled>
<CheckboxStyled type="checkbox" />
</ChartContainerStyled>
<ChartContainerStyled>
<LineContainerStyled>
<Line trailWidth="2" trailColor="red" strokeWidth="4" strokeColor="green" percent={percent}/>
</LineContainerStyled>
<h6>{percent}%</h6>
</ChartContainerStyled>
</ContainerStyled>
)
};
export default ReportProgressSummary;
Thanks for any help
In timer = setTimeout(setPercent(newPercent), 1000);
setPercent(newPercent) will be called immediately. setTimeout requires a function (instead of a function call):
timer = setTimeout(() => setPercent(newPercent), 1000);
Another thing to keep in mind is that timer will be defined on each render, thus clearTimeout won't have any effect. To avoid this, you can create a ref so that the value will be remembered. Like so:
const timer = React.useRef()
using/setting the value is done to the current property
timer.current = ...
Working example:
const ReportProgressSummary = ({result, title}) => {
const [percent, setPercent] = useState(0);
const timer = React.useRef();
useEffect( () => {
let newPercent = percent + 1;
if (newPercent > result) {
clearTimeout(timer.current);
return;
}
timer.current = setTimeout(() => setPercent(newPercent), 1000);
}, [percent]);
return (
<ContainerStyled>
<ChartContainerStyled>
<ChartTitleStyled>{title}</ChartTitleStyled>
<CheckboxStyled type="checkbox" />
</ChartContainerStyled>
<ChartContainerStyled>
<LineContainerStyled>
<Line trailWidth="2" trailColor="red" strokeWidth="4" strokeColor="green" percent={percent}/>
</LineContainerStyled>
<h6>{percent}%</h6>
</ChartContainerStyled>
</ContainerStyled>
)
};
export default ReportProgressSummary;