How can one prevent duplicate setTimeout resulting from complex state change? - reactjs

I apologize for the complexity of the code this question is based around, but it seems the issue itself is arising from the complexity. I wasn't able to replicate the issue with a simpler example. Here is the code repository branch with the issue: https://github.com/chingu-voyages/v42-geckos-team-21/commit/3c20cc55e66e7d0f9122d843222980e404d4910f The left hand (before the change) uses useRef() and does not have the issue, but I don't think useRef() respect's its proper usage.
Here is the main problem code:
import { useState, useRef} from 'react';
import "./Alert.css"
import "animate.css"
import { CSSTransition } from "react-transition-group"
import { time } from 'console';
console.log(typeof CSSTransition);
interface IfcProps {
text: React.ReactNode,
exitAfterDuration: number,
setAlertKey: React.Dispatch<React.SetStateAction<number>>,
alertKey: number
}
const classNames = {
appear: 'animate__bounce',
appearActive: 'animate__bounce',
appearDone: 'animate__bounce',
enter: 'animate__bounce',
enterActive: 'animate__bounce',
enterDone: 'animate__bounce',
exit: 'animate__bounce',
exitActive: 'animate__fadeOut',
exitDone: 'animate__fadeOut'
}
function Alert(props: IfcProps) {
const nodeRef = useRef(null);
let [isIn, setIsIn] = useState(false);
let [previousAlertKey, setPreviousAlertKey] = useState(0);
let [timeoutId, setTimeoutId] = useState<number | null>(null);
// console.log('props', {...props});
console.log('prev, pres', previousAlertKey, props.alertKey)
console.log('state', {isIn, previousAlertKey, timeoutId});
// console.log('prev, current:', previousAlertKey, props.alertKey);
if (props.text === '') {
// do not render if props.text === ''
return null;
} else if (previousAlertKey !== props.alertKey) {
setIsIn(true);
setPreviousAlertKey(oldPreviousAlertKey => {
oldPreviousAlertKey++
return oldPreviousAlertKey;
});
if (timeoutId) {
console.log(timeoutId, 'timeout cleared');
clearTimeout(timeoutId);
}
let localTimeoutId = window.setTimeout(() => {
console.log('executing timeout')
setIsIn(false);
}, props.exitAfterDuration);
console.log({localTimeoutId}, previousAlertKey, props.alertKey);
setTimeoutId(localTimeoutId);
}
return (
<CSSTransition nodeRef={nodeRef} in={isIn} appear={true} timeout={1000} classNames={classNames}>
{/* Using key here to trigger rebounce on alertKey change */}
<div ref={nodeRef} id="alert" className="animate__animated animate__bounce" key={props.alertKey}>
{props.text}
</div>
</CSSTransition>
)
}
export default Alert
Code that resolves issue but probably uses useRef() incorrectly:
import { useState, useRef } from 'react';
import "./Alert.css"
import "animate.css"
import { CSSTransition } from "react-transition-group"
import { time } from 'console';
console.log(typeof CSSTransition);
interface IfcProps {
text: React.ReactNode,
exitAfterDuration: number,
setAlertKey: React.Dispatch<React.SetStateAction<number>>,
alertKey: number
}
const classNames = {
appear: 'animate__bounce',
appearActive: 'animate__bounce',
appearDone: 'animate__bounce',
enter: 'animate__bounce',
enterActive: 'animate__bounce',
enterDone: 'animate__bounce',
exit: 'animate__bounce',
exitActive: 'animate__fadeOut',
exitDone: 'animate__fadeOut'
}
function Alert(props: IfcProps) {
const nodeRef = useRef(null);
const timeoutIdRef = useRef<number | null>(null);
let [isIn, setIsIn] = useState(false);
let [previousAlertKey, setPreviousAlertKey] = useState(0);
console.log({props});
console.log('state', {isIn, previousAlertKey, timeoutIdRef});
console.log('prev, current:', previousAlertKey, props.alertKey);
if (props.text === '') {
// do not render if props.text === ''
return null;
} else if (previousAlertKey !== props.alertKey) {
setIsIn(true);
setPreviousAlertKey(oldPreviousAlertKey => {
oldPreviousAlertKey++
return oldPreviousAlertKey;
});
if (timeoutIdRef.current) {
console.log(timeoutIdRef.current, 'timeout cleared');
clearTimeout(timeoutIdRef.current);
}
let localTimeoutId = window.setTimeout(() => setIsIn(false), props.exitAfterDuration);
console.log({localTimeoutId}, previousAlertKey, props.alertKey);
timeoutIdRef.current = localTimeoutId;
}
return (
<CSSTransition nodeRef={nodeRef} in={isIn} appear={true} timeout={1000} classNames={classNames}>
{/* Using key here to trigger rebounce on alertKey change */}
<div ref={nodeRef} id="alert" className="animate__animated animate__bounce" key={props.alertKey}>
{props.text}
</div>
</CSSTransition>
)
}
export default Alert
The issue shows its head when an invalid row is attempted to be submitted to the database and the Alert component appears. If multiple alerts are triggered in this way, they all disappear when the first setTimeout expires because it was never cleared properly. One timeout should be cleared but because React strict mode renders twice and the creation of a timeout is a side effect, the extra timeout never gets cleared. React isn't aware that there are two timeouts running for every submission attempt (check-mark click).
I'm probably handling my alert component incorrectly, with the alertKey for example.
I feel my problem is related to the fact that my setTimeout is triggered inside the Alert component as opposed to inside the Row component's onClick() handler, as I did so in a simpler example and it did not exhibit the issue.
I fear I may not get any replies as this is a pretty ugly and complex case that requires a fair bit of setup to the dev environment. This may well be a case where I just have to cobble together a solution (e.g. with useRef) and learn the proper React way in the future through experience. Tunnel-vision is one of my faults.

tl;dr Use the dependency array in useHook()
So I took a step back and worked on some other parts of the app, while sometimes doing some research into how others handle a toast notification component, which is what I was effectively working on in the code here. Logrocket's article was helpful: How to create a custom toast component with React.
#Azzy helped put me back on the right track and the article above also uses the useEffect() hook for the timeout.
In my spare time, eventually I came across this article A Simple Explanation of React.useEffect(). The author, Dmitri Pavlutin, finally got it into my head the intended relation of the main component function body and the useEffect hook.
A functional React component uses props and/or state to calculate the
output. If the functional component makes calculations that don't
target the output value, then these calculations are named
side-effects.
. . .
The component rendering and side-effect logic are independent. It
would be a mistake to perform side-effects directly in the body of the
component, which is primarily used to compute the output.
How often the component renders isn't something you can control — if
React wants to render the component, you cannot stop it.
. . .
How to decouple rendering from the side-effect? Welcome useEffect() —
the hook that runs side-effects independently of rendering.
I now present the code that works and is the React way, (or at least is more like the optimal React way than my two previous attempts, see above).
function Alert(props: IfcProps) {
useEffect(() => {
let timeoutId = window.setTimeout(() => setIsIn(false), props.exitAfterDuration);
return function cleanup() {
window.clearTimeout(timeoutId);
}
}, [props.alertKey]);
const nodeRef = useRef(null);
const timeoutIdRef = useRef<number | null>(null);
let [isIn, setIsIn] = useState(false);
let [previousAlertKey, setPreviousAlertKey] = useState(0);
console.log({ props });
console.log('state', { isIn, previousAlertKey, timeoutIdRef });
console.log('prev, current:', previousAlertKey, props.alertKey);
if (props.text === '') {
// do not render if props.text === ''
return null;
} else if (previousAlertKey !== props.alertKey) {
setIsIn(true);
setPreviousAlertKey(oldPreviousAlertKey => {
oldPreviousAlertKey++
return oldPreviousAlertKey;
});
}
return (
<CSSTransition nodeRef={nodeRef} in={isIn} appear={true} timeout={1000} classNames={classNames}>
{/* Using key here to trigger rebounce on alertKey change */}
<div ref={nodeRef} id="alert" className="animate__animated animate__bounce" key={props.alertKey}>
{props.text}
</div>
</CSSTransition>
)
}
export default Alert
To be honest, there's probably a lot of refactoring I can do now that I understand the utility of useEffect(). I likely have other components with side effects dependent on logic dedicated to checking if the current render happened because specific state / props changed. useEffect()'s dependency array is a lot cleaner than conditionals in the function body checking for those state/prop changes.
A lot of the complexity I lamented in my question arose I think because I wasn't properly separating side effects from my main function body.
Thank you for coming to my TED talk. :)

Related

React Context value gets updated, but component doesn't re-render

This Codesandbox only has mobile styles as of now
I currently have a list of items being rendered based on their status.
Goal: When the user clicks on a nav button inside the modal, it updates the status type in context. Another component called SuggestionList consumes the context via useContext and renders out the items that are set to the new status.
Problem: The value in context is definitely being updated, but the SuggestionList component consuming the context is not re-rendering with a new list of items based on the status from context.
This seems to be a common problem:
Does new React Context API trigger re-renders?
React Context api - Consumer Does Not re-render after context changed
Component not re rendering when value from useContext is updated
I've tried a lot of suggestions from different posts, but I just cannot figure out why my SuggestionList component is not re-rendering upon value change in context. I'm hoping someone can give me some insight.
Context.js
// CONTEXT.JS
import { useState, createContext } from 'react';
export const RenderTypeContext = createContext();
export const RenderTypeProvider = ({ children }) => {
const [type, setType] = useState('suggestion');
const renderControls = {
type,
setType,
};
console.log(type); // logs out the new value, but does not cause a re-render in the SuggestionList component
return (
<RenderTypeContext.Provider value={renderControls}>
{children}
</RenderTypeContext.Provider>
);
};
SuggestionPage.jsx
// SuggestionPage.jsx
export const SuggestionsPage = () => {
return (
<>
<Header />
<FeedbackBar />
<RenderTypeProvider>
<SuggestionList />
</RenderTypeProvider>
</>
);
};
SuggestionList.jsx
// SuggestionList.jsx
import { RenderTypeContext } from '../../../../components/MobileModal/context';
export const SuggestionList = () => {
const retrievedRequests = useContext(RequestsContext);
const renderType = useContext(RenderTypeContext);
const { type } = renderType;
const renderedRequests = retrievedRequests.filter((req) => req.status === type);
return (
<main className={styles.container}>
{!renderedRequests.length && <EmptySuggestion />}
{renderedRequests.length &&
renderedRequests.map((request) => (
<Suggestion request={request} key={request.title} />
))}
</main>
);
};
Button.jsx
// Button.jsx
import { RenderTypeContext } from './context';
export const Button = ({ handleClick, activeButton, index, title }) => {
const tabRef = useRef();
const renderType = useContext(RenderTypeContext);
const { setType } = renderType;
useEffect(() => {
if (index === 0) {
tabRef.current.focus();
}
}, [index]);
return (
<button
className={`${styles.buttons} ${
activeButton === index && styles.activeButton
}`}
onClick={() => {
setType('planned');
handleClick(index);
}}
ref={index === 0 ? tabRef : null}
tabIndex="0"
>
{title}
</button>
);
};
Thanks
After a good night's rest, I finally solved it. It's amazing what you can miss when you're tired.
I didn't realize that I was placing the same provider as a child of itself. Once I removed the child provider, which was nested within itself, and raised the "parent" provider up the tree a little bit, everything started working.
So the issue wasn't that the component consuming the context wasn't updating, it was that my placement of providers was conflicting with each other. I lost track of my component tree. Dumb mistake.
The moral of the story, being tired can make you not see solutions. Get rest.

How to set window resize event listener value to React State?

This issue is very simple but I probably overlook very little point. Window screen size is listening by PostLayout component. When window width is less than 768px, I expect that isDesktopSize is false. I tried everything like using arrow function in setIsDesktopSize, using text inside of true or false for state value, using callback method etc... but it's not working.
PostLayout shared below:
import React, {useState,useEffect, useCallback} from 'react'
import LeftSideNavbar from './LeftSideNavbar'
import TopNavbar from './TopNavbar'
export default function PostLayout({children}) {
const [isDesktopSize, setIsDesktopSize] = useState(true)
let autoResize = () => {
console.log("Desktop: " + isDesktopSize);
console.log(window.innerWidth);
if(window.innerWidth < 768 ){
setIsDesktopSize(false)
}else{
setIsDesktopSize(true)
}
}
useEffect(() => {
window.addEventListener('resize', autoResize)
autoResize();
}, [])
return (
<>
<TopNavbar isDesktopSize={isDesktopSize}/>
<main>
<LeftSideNavbar/>
{children}
</main>
</>
)
}
console log is shared below:
Desktop: true
627
This could probably be extracted into a custom hook. There's a few things you'd want to address:
Right now you default the state to true, but when the component loads, that may not be correct. This is probably why you see an incorrect console log on the first execution of the effect. Calculating the initial state to be accurate could save you some jank/double rendering.
You aren't disconnecting the resize listener when the component unmounts, which could result in an error attempting to set state on the component after it has unmounted.
Here's an example of a custom hook that addresses those:
function testIsDesktop() {
if (typeof window === 'undefined') {
return true;
}
return window.innerWidth >= 768;
}
function useIsDesktopSize() {
// Initialize the desktop size to an accurate value on initial state set
const [isDesktopSize, setIsDesktopSize] = useState(testIsDesktop);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
function autoResize() {
setIsDesktopSize(testIsDesktop());
}
window.addEventListener('resize', autoResize);
// This is likely unnecessary, as the initial state should capture
// the size, however if a resize occurs between initial state set by
// React and before the event listener is attached, this
// will just make sure it captures that.
autoResize();
// Return a function to disconnect the event listener
return () => window.removeEventListener('resize', autoResize);
}, [])
return isDesktopSize;
}
Then to use this, your other component would look like this (assuming your custom hook is just in this same file -- though it may be useful to extract it to a separate file and import it):
import React, { useState } from 'react'
import LeftSideNavbar from './LeftSideNavbar'
import TopNavbar from './TopNavbar'
export default function PostLayout({children}) {
const isDesktopSize = useIsDesktopSize();
return (
<>
<TopNavbar isDesktopSize={isDesktopSize}/>
<main>
<LeftSideNavbar/>
{children}
</main>
</>
)
}
EDIT: I modified this slightly so it should theoretically work with a server-side renderer, which will assume a desktop size.
Try this, you are setting isDesktopSizze to 'mobile', which is === true
const [isDesktopSize, setIsDesktopSize] = useState(true)
let autoResize = () => {
console.log("Desktop: " + isDesktopSize);
console.log(window.innerWidth);
if(window.innerWidth < 768 ){
setIsDesktopSize(true)
}else{
setIsDesktopSize(false)
}
}
I didn't find such a package on npm and I thought it would be nice to create one: https://www.npmjs.com/package/use-device-detect. I think it will help someone :)

React native: useState not updating correctly

I'm new to react native and currently struggling with an infinite scroll listview. It's a calendar list that need to change depending on the selected company (given as prop). The thing is: the prop (and also the myCompany state are changed, but in the _loadMoreAsync method both prop.company as well as myCompany do hold their initial value.
import * as React from 'react';
import { FlatList } from 'react-native';
import * as Api from '../api/api';
import InfiniteScrollView from 'react-native-infinite-scroll-view';
function CalenderFlatList(props: { company: any }) {
const [myCompany, setMyCompany] = React.useState(null);
const [data, setData] = React.useState([]);
const [canLoadMore, setCanLoadMore] = React.useState(true);
const [startDate, setStartDate] = React.useState(undefined);
let loading = false;
React.useEffect(() => {
setMyCompany(props.company);
}, [props.company]);
React.useEffect(() => {
console.log('set myCompany to ' + (myCompany ? myCompany.name : 'undefined'));
_loadMoreAsync();
}, [myCompany]);
async function _loadMoreAsync() {
if ( loading )
return;
loading = true;
if ( myCompany == null ) {
console.log('no company selected!');
return;
} else {
console.log('use company: ' + myCompany.name);
}
Api.fetchCalendar(myCompany, startDate).then((result: any) => {
// code is a little more complex here to keep the already fetched entries in the list...
setData(result);
// to above code also calculates the last day +1 for the next call
setStartDate(lastDayPlusOne);
loading = false;
});
}
const renderItem = ({ item }) => {
// code to render the item
}
return (
<FlatList
data={data}
renderScrollComponent={props => <InfiniteScrollView {...props} />}
renderItem={renderItem}
keyExtractor={(item: any) => '' + item.uid }
canLoadMore={canLoadMore}
onLoadMoreAsync={() => _loadMoreAsync() }
/>
);
}
What I don't understand here is why myCompany is not updating at all in _loadMoreAsync while startDate updates correctly and loads exactly the next entries for the calendar.
After the prop company changes, I'd expect the following output:
set myCompany to companyName
use company companyName
But instead i get:
set myCompany to companyName
no company selected!
I tried to reduce the code a bit to strip it down to the most important parts. Any suggestions on this?
Google for useEffect stale closure.
When the function is called from useEffect, it is called from a stale context - this is apparently a javascript feature :) So basically the behavior you are experiencing is expected and you need to find a way to work around it.
One way to go may be to add a (optional) parameter to _loadMoreAsync that you pass from useEffect. If this parameter is undefined (which it will be when called from other places), then use the value from state.
Try
<FlatList
data={data}
renderScrollComponent={props => <InfiniteScrollView {...props} />}
renderItem={renderItem}
keyExtractor={(item: any) => '' + item.uid }
canLoadMore={canLoadMore}
onLoadMoreAsync={() => _loadMoreAsync() }
extraData={myCompany}
/>
If your FlatList depends on a state variable, you need to pass that variable in to the extraData prop to trigger a re-rendering of your list. More info here
After sleeping two nights over the problem I solved it by myself. The cause was an influence of another piece of code that used React.useCallback(). And since "useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed" (https://reactjs.org/docs/hooks-reference.html#usecallback) the code worked with the old (or initial) state of the variables.
After creating the whole page new from scratch I found this is the reason for that behavior.

useCallback with dependency vs using a ref to call the last version of the function

While doing a code review, I came across this custom hook:
import { useRef, useEffect, useCallback } from 'react'
export default function useLastVersion (func) {
const ref = useRef()
useEffect(() => {
ref.current = func
}, [func])
return useCallback((...args) => {
return ref.current(...args)
}, [])
}
This hook is used like this:
const f = useLastVersion(() => { // do stuff and depends on props })
Basically, compared to const f = useCallBack(() => { // do stuff }, [dep1, dep2]) this avoids to declare the list of dependencies and f never changes, even if one of the dependency changes.
I don't know what to think about this code. I don't understand what are the disadvantages of using useLastVersion compared to useCallback.
That question is actually already more or less answered in the documentation: https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback
The interesting part is:
Also note that this pattern might cause problems in the concurrent mode. We plan to provide more ergonomic alternatives in the future, but the safest solution right now is to always invalidate the callback if some value it depends on changes.
Also interesting read: https://github.com/facebook/react/issues/14099 and https://github.com/reactjs/rfcs/issues/83
The current recommendation is to use a provider to avoid to pass callbacks in props if we're worried that could engender too many rerenders.
My point of view as stated in the comments, that this hook is redundant in terms of "how many renders you get", when there are too frequent dependencies changes (in useEffect/useCallback dep arrays), using a normal function is the best option (no overhead).
This hook hiding the render of the component using it, but the render comes from the useEffect in its parent.
If we summarize the render count we get:
Ref + useCallback (the hook): Render in Component (due to value) + Render in hook (useEffect), total of 2.
useCallback only: Render in Component (due to value) + render in Counter (change in function reference duo to value change), total of 2.
normal function: Render in Component + render in Counter : new function every render, total of 2.
But you get additional overhead for shallow comparison in useEffect or useCallback.
Practical example:
function App() {
const [value, setValue] = useState("");
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
type="text"
/>
<Component value={value} />
</div>
);
}
function useLastVersion(func) {
const ref = useRef();
useEffect(() => {
ref.current = func;
console.log("useEffect called in ref+callback");
}, [func]);
return useCallback((...args) => {
return ref.current(...args);
}, []);
}
function Component({ value }) {
const f1 = useLastVersion(() => {
alert(value.length);
});
const f2 = useCallback(() => {
alert(value.length);
}, [value]);
const f3 = () => {
alert(value.length);
};
return (
<div>
Ref and useCallback:{" "}
<MemoCounter callBack={f1} msg="ref and useCallback" />
Callback only: <MemoCounter callBack={f2} msg="callback only" />
Normal: <MemoCounter callBack={f3} msg="normal" />
</div>
);
}
function Counter({ callBack, msg }) {
console.log(msg);
return <button onClick={callBack}>Click Me</button>;
}
const MemoCounter = React.memo(Counter);
As a side note, if the purpose is only finding the length of input with minimum renders, reading inputRef.current.value would be the solution.

Ensure entrance css transition with React component on render

I am trying to build a barebones css transition wrapper in React, where a boolean property controls an HTML class that toggles css properties that are set to transition. For the use case in question, we also want the component to be unmounted (return null) before the entrance transition and after the exit transition.
To do this, I use two boolean state variables: one that controls the mounting and one that control the HTML class. When props.in goes from false to true, I set mounted to true. Now the trick: if the class is set immediate to "in" when it's first rendered, the transition does not occur. We need the component to be rendered with class "out" first and then change the class to "in".
A setTimeout works but is pretty arbitrary and not strictly tied to the React lifecycle. I've found that even a timeout of 10ms can sometimes fail to produce the effect. It's a crapshoot.
I had thought that using useEffect with mounted as the dependency would work because the component would be rendered and the effect would occur after:
useEffect(if (mounted) { () => setClass("in"); }, [mounted]);
(see full code in context below)
but this fails to produce the transition. I believe this is because React batches operations and chooses when to render to the real DOM, and most of the time doesn't do so until after the effect has also occurred.
How can I guarantee that my class value is change only after, but immediately after, the component is rendered after mounted gets set to true?
Simplified React component:
function Transition(props) {
const [inStyle, setInStyle] = useState(props.in);
const [mounted, setMounted] = useState(props.in);
function transitionAfterMount() {
// // This can work if React happens to render after mounted get set but before
// // the effect; but this is inconsistent. How to wait until after render?
setInStyle(true);
// // this works, but is arbitrary, pits UI delay against robustness, and is not
// // tied to the React lifecycle
// setTimeout(() => setInStyle(true), 35);
}
function unmountAfterTransition() {
setTimeout(() => setMounted(false), props.duration);
}
// mount on props.in, or start exit transition on !props.in
useEffect(() => {
props.in ? setMounted(true) : setInStyle(false);
}, [props.in]);
// initiate transition after mount
useEffect(() => {
if (mounted) { transitionAfterMount(); }
}, [mounted]);
// unmount after transition
useEffect(() => {
if (!props.in) { unmountAfterTransition(); }
}, [props.in]);
if (!mounted) { return false; }
return (
<div className={"transition " + inStyle ? "in" : "out"}>
{props.children}
</div>
)
}
Example styles:
.in: {
opacity: 1;
}
.out: {
opacity: 0;
}
.transition {
transition-property: opacity;
transition-duration: 1s;
}
And usage
function Main() {
const [show, setShow] = useState(false);
return (
<>
<div onClick={() => setShow(!show)}>Toggle</div>
<Transition in={show} duration={1000}>
Hello, world.
</Transition>
<div>This helps us see when the above component is unmounted</div>
</>
);
}
Found the solution looking outside of React. Using window.requestAnimationFrame allows an action to be take after the next DOM paint.
function transitionAfterMount() {
// hack: setTimeout(() => setInStyle(true), 35);
// not hack:
window.requestAnimationFrame(() => setInStyle(true));
}

Resources