State not captured by callback in React component - javascript

I have this component that uses useState, and wraps its children in a Modal component, which is an old component from our application, and the modal component happens to be a total nightmare. But it takes an acceptData object which has a callback prop for when the "accept" button is clicked.
My issue is that my component is getting its state updated via another child (see onFileReadComplete), but then later when the button is clicked, that state is null. I can even see in the React dev tools that the state is not null, it has the value I expect.
Here is the component:
const CSVEditSwitcher = ({ acceptData, hideData }) => {
const [parsedData, setParsedData] = useState(null);
const dispatch = useDispatch();
const enterLineups = lineupObject => dispatch(csvEditAPI(lineupObject));
const modalProps = {
className: "csv-upload",
commonHide: true,
acceptData: {
actionName: "Submit",
className: acceptData.className,
callback: () => {
if (!parsedData) {
console.log("no data!"); // we keep getting here!
return;
}
enterLineups(parsedData.masterLineups).then(acceptData.callback);
},
},
hideData,
validator: () => true,
};
return (
<Modal {...modalProps}>
{!parsedData ? (
<UploadEditTemplate
onFileReadComplete={data => {
console.log({ data }); // this is always correct!
setParsedData(data);
}}
/>
) : (
<ImportResults {...parsedData} />
)}
</Modal>
);
};
In case it's some crazy this issue, I've tried replacing the callback with a function () { } syntax, to no avail.
There's nothing interesting in the call point for the callback in Modal:
accept(e) {
if (!e) return;
if (e.key && (e.key !== "Enter" || e.key !== "Return")) return;
const { acceptCallback } = this.state;
console.log(acceptCallback, e);
if (acceptCallback) acceptCallback(e);
}
Update
As a hail-mary, I tried converting this to a class component (wrapping it in a functional component that provides the hook values). It actually works, but only when accessing this.state.parsedData explicitly in the callback. i.e., I have const { parsedData } = this.state at the top of the render method for the other uses of that value, but the destructured variable does not work in the callback.
I would love if anyone could lend insight on what the hell is going on here!

While destructuring props in injection ({...modalProps}...), you create a new instance in memory. Therefore callback prop will always have initial parsedData value.
My suggestion is to use callback as a reference to useCallback which aware to state manipulation
const callback = useCallback(() => {
if (!parsedData) {
console.log("no data!")
return;
}
enterLineups(parsedData.masterLineups).then(acceptData.callback);
}, [parsedData])
const modalProps = {
className: "csv-upload",
commonHide: true,
acceptData: {
actionName: "Submit",
className: acceptData.className,
callback
},
hideData,
validator: () => true,
};

Related

Value of variable outside of useEffect hook has old data

What the code does: It's performing a DOM search based on what's typed in an input (it's searching elements by text). All this is happening in a React component.
import { useEffect, useReducer } from "react";
let elements: any[] = [];
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
elements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
console.log('elements 1:', elements)
}, [state]);
console.log('elemets 2:', elements)
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
The problem: The value of elements outside of useEffect is the old data. For example, if you type a in the input, console.log('elements 1:', elements) will log 1, and console.log('elements 2:', elements) will log 0. Note: there are 3 buttons, and one of them has the text a.
The strange thing is that the problem doesn't happen if you comment out this if-statement:
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
In this case, if you type anything (since the pattern matching has been commented out), console.log('elements 1:', elements) and console.log('elements 2:', elements) will log 3. Note: there are 3 buttons.
Question: What could be the problem, and how to fix it? I want to render the current length of elements.
Live code:
It's happening because of the elements variable is not a state, so it's not reactive.
Create a state for the elements:
const [elements, setElements] = useState<HTMLButtonElement[]>([])
And use this state to handle the elements.
import { useEffect, useReducer, useState } from "react";
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
const [elements, setElements] = useState<HTMLButtonElement[]>([])
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
let newElements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
setElements(newElements)
console.log("elements 1:", elements?.length);
}, [state]);
console.log("elemets 2:", elements?.length);
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements?.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
Your useEffect() runs after your component has rendendered. So the sequence is:
You type something into input, that triggers handleInputChange
handleInputChange then updates your state using updateState()
The state update causes a rerender, so App is called App()
console.log('elemets 2:', elements.length) runs and logs elements as 0 as it's still empty
App returns the new JSX
Your useEffect() callback runs, updating elements
Notice how we're only updating the elements after you've rerendered and App has been called.
The state of your React app should be used to describe your UI in React. Since elements isn't React state, it has a chance of becoming out of sync with the UI (as you've seen), whereas using state doesn't have this issue as state updates always trigger a UI update. Consider making elements part of your state. If it needs to be accessible throughout your entire App, you can pass it down as props to children components, or use context to make it accessible throughout all your components.
With that being said, I would make the following updates:
Add elements to your state
Remove your useEffect() with the dependency of [state]. If we were to update the elements state within this effect, then that would trigger another rerender directly after the one we just did for the state update. This isn't efficient, and instead, we can tie the update directly to your event handler. See You Might Not Need an Effect for more details and dealing with other types of scenarios:
<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>
<script src="https://unpkg.com/#babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useEffect, useReducer} = React;
const App = () => {
const initialState = {keyEvent: {}, value: "Initial state", elements: []};
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates}),
initialState
);
function searchDOM(value) {
const selectors = "button";
const pattern = new RegExp(value === "" ? "^$" : value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
return nodeWithText?.textContent?.match(pattern);
}
return false;
}
);
}
function handleInputChange(event) {
updateState({
value: event.target.value,
elements: searchDOM(event.target.value)
});
}
function isCommand(event) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event) {
if (isCommand(event)) {
updateState({
keyEvent: event
});
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
console.log("elements:", state.elements.length);
return (
<div>
<input id="input" type="text" onChange={handleInputChange} value={state.value} />
<div id="count">{state.elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
ReactDOM.createRoot(document.body).render(<App />);
</script>
useEffect triggered after react completed its render phase & flush the new changes to the DOM.
In your case you have two useEffects. The first one register your event lister which will then update your component state when input field change. This triggers a state update.( because of the setState )
So React will start render the component again & finish the cycle. And now you have 2nd useEffect which has state in dependency array. Since the state was updated & the new changes are committed to the DOM, react will execute 2nd useEffect logic.
Since your 2nd useEffect just assign some values to a normal variable React will not go re render your component again.
Based on your requirement you don't need a 2nd useEffect. You can use a useMemo,
let elements = useMemo(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
})
}, [state])
Note: You don't need to assign your elements into another state. It just create another unwanted re render in cycle. Since you are just doing a calculation to find out the element array you can do it with the useMemo

How do I avoid a useEffect loop when the dep is a function from a default parameter?

I'm writing a video component and I've run into a useEffect rendering loop with an event function prop called onParentNotify.
function VideoPlayer({
videoPath = "",
onNotifyParent = () => null, // <-- this is where the problem is.
}) {
const [status, setStatus] = useState(null);
const videoRef = useRef();
const isLoading = status?.isLoading;
useEffect(() => {
if (!isLoading) {
onNotifyParent("log has loaded!");
}
}, [isLoading, videoPath, onNotifyParent]);
return (<Video
ref={videoRef}
videoPath={videoPath}
onStatus={(status) => setStatus(status)}
/>)
}
I memoized this function in the outer ParentComponent1 which prevented the loop.
function ParentComponent1() {
// Yay, works!
const handleNotifyParent = useCallback((msg = "") => {
console.log(msg);
});
return (<VideoPlayer
videoPath="http://video-url-here.com"
onNotifyParent={handleNotifyParent}
>);
}
But with ParentComponent2, that component doesn't pass the prop, so it defaults to onNotifyParent = () => null in the props declaration for VideoPlayer, causing the loop.
// Eeek! Rendering loops ahead
function ParentComponent2() {
return <VideoPlayer videoPath="http://video-url-here.com">;
}
My question is: how should I go about memoizing the default prop parameter onNotifyParent when the prop is undefined? Two solutions come to mind, but they seem a bit clunky:
Define default function outside the component scope.
const DEFAULT_FUNC = () => null;
function VideoPlayer({
videoPath = "",
onNotifyParent = DEFAULT_FUNC, // ew
onSomethingElse = DEFAULT_FUNC,
onAnotherThing = DEFAULT_FUNC,
}) {
// ...
}
Pass null as the default and call the function conditionally.
function VideoPlayer({
videoPath = "",
onNotifyParent = null
}) {
useEffect(() => {
if (!isLoading) {
onNotifyParent?.("log has loaded!"); // feels wrong...
}
}, [isLoading, videoPath, onNotifyParent]);
}
Or is there a better/different convention to follow in this case?
Based on the discussion here, I've decided to go with option #2 - passing null as the default and calling the function conditionally with null coalescing:
function VideoPlayer({
videoPath = "",
onNotifyParent = null, // <-- here
}) {
useEffect(() => {
if (!isLoading) {
onNotifyParent?.("log has loaded!"); // <-- and here
}
}, [isLoading, videoPath, onNotifyParent]);
}
In future I may turn to TypeScript or React prop-types and end up with a solution that resembles option #1, but for the time being I'm just going to keep things simple and avoid setting default parameter functions. Thanks all for your time today.

React useCallback hook: What are the correct dependencies for these handleChange & handleSubmit functions to prevent re rendering?

I have a login component which unnecessarily re-renders. I have wrapped the component with React.memo and am using the useCallBack hooks to prevent those functions from getting created on every render if there values don't change...
Consider the following:
I have a generic FormComponent which gets one prop:
function FormComponent({
formType
}) { ....}
export default React.memo(FormComponent)
That prop will switch between the different forms I have e.g. login, registration etc.
In my example I am just showing the login form, I figure I'll just apply the solution to the others.
function FormComponent({
formType
}) {
/* various setstate removed for brevity */
const Forms = {
Login: [LoginForm,
() => loginSubmit(
email,
password,
setEmail,
setPassword,
setFormError,
setFormSuccess,
setIsLoading,
setResponseMessage,
dispatch,
router,
user,
mutate
)
]
};
function handleChangeForUseCallBack(name, value) {
setResponseMessage('');
setPasswordFeedback('')
setPasswordConfirmationFeedback('')
setFormError(false);
setFormSuccess(false);
setEmailError(false);
setPasswordError(false);
setPasswordConfirmationError(false);
setDisableButton(false);
dispatch({ type: 'resetUserAccountIsVerified', })
setEmailDup(false);
if (value === '') setDisableButton(() => true)
if (name === 'email') {
setEmail(value);
}
if (name === 'password') {
setPassword(value);
}
if (name === 'password_confirmation') {
setPasswordConfirmation(value);
}
if (name === 'current_location') {
setCurrentLocation(value);
}
if (name === 'current_destination') {
setCurrentDestination(value);
}
if (name === 'interested_activities') {
setInterestedActivitiesInput(value);
}
}
const handleChange = useCallback((e) => {
e.persist();
const { name, value } = e.target;
handleChangeForUseCallBack(name, value);
}, [email, formType, password, password_confirmation, handleChangeForUseCallBack, setIsLoading]);
function handleSubmitForUseCallBack(e, form) {
e.preventDefault();
setDisableButton(true);
validateInputs(
form,
email,
setEmailError,
setEmailFeedback,
password,
password_confirmation,
setPasswordConfirmationError,
setPasswordConfirmationFeedback,
setPasswordError,
setPasswordFeedback,
setFormSuccess,
setFormError,
);
return preventSubmit ? false : Forms[form][1]()
}
const handleSubmit = useCallback((e, form) => {
handleSubmitForUseCallBack(e, form);
}, [email, password, password_confirmation, interestedActivities, handleSubmitForUseCallBack]);
function LoginForm() {
useEffect(() => {
dispatch({ type: 'resetUserAccountIsVerified' })
}, [id]);
return (
mounted && <GenericFormComponent
handleSubmit={handleSubmit}
formType={formType}
formSuccess={formSuccess}
formError={formError}
accountNotVerified={accountNotVerified}
email={email}
emailError={emailError}
emailFeedback={emailFeedback}
handleChange={handleChange}
password={password}
passwordError={passwordError}
passwordFeedback={passwordFeedback}
disableButton={disableButton}
buttonName="Log-in"
isLoading={isLoading}
setIsLoading={setIsLoading}
responseMessage={responseMessage}
/>
);
}
return Forms[formType][0]();
}
Is the problem the handleSubmit has a various function calls and values being passsed in which need to be passed into the useCallback dependencies?
Any help would be appreciated!
I don't think useCallback is the issue here. One potential cause may be the following side effect running when the LoginForm is rendered:
useEffect(() => {
dispatch({ type: 'resetUserAccountIsVerified' })
}, [id]);
but I may be wrong. I think the definite issue here is the fact that LoginForm is defined inside of your FormComponent. Since its defined inside of FormComponent each time FormComponentis reevaluated (from a state change, for example), LoginForm would be reinitialized, and thus if it's already rendered, then it will rerender. I think defining the LoginForm elsewhere would solve your problem.

How to implement setState callback patten with useState

I have multiple setState(newState, callback) statements in my Class component. Now that I'm shifting to using hooks, I am unable to put all of the callbacks in a useEffect and call them conditionally.
Some setState calls are not distinguishable with regard to what changes they are making (they may be changing the same array stored in the useState) but they all fire very different callbacks. I cannot simply just put different conditions in useState and fire callbacks.
The whole logic is becoming much more complex. How have you handled changing setState to useState without affecting the callbacks or having to make very complex logic changes inside useEffect?
Example code:
resetSelections = () => {
const { filterData, toggleSidebarOnReset, toggleSidebar } = this.props;
this.setState(
{
selections: getSelectionsFromFilterData(filterData),
},
() => {
this.triggerAdvancedFilter();
if (toggleSidebarOnReset && toggleSidebar) {
toggleSidebar();
}
if (this.props.removeFilterDefaultSelection) {
this.props.removeFilterDefaultSelection();
}
}
);
};
addCustomField = filterGroupData => {
this.setState(
prevState => ({
customSelectionsMap: {
...prevState.customSelectionsMap,
[filterGroupData.name]: filterGroupData.id,
},
selections: {
...prevState.selections,
[filterGroupData.name]: [],
},
}),
() => this.props.addCustomFieldInFilterData(filterGroupData)
);
};
removeCustomField = data => {
const { selections, customSelectionsMap } = this.state;
const newSelections = { ...selections };
const newCustomSelectionsMap = { ...customSelectionsMap };
delete newSelections[data.name];
delete newCustomSelectionsMap[data.name];
this.setState(
{
selections: newSelections,
customSelectionsMap: newCustomSelectionsMap,
},
() => {
this.props.removeCustomFieldFromFilterData(data);
this.triggerAdvancedFilter();
}
);
};
addToSelection = ({ group, name }, isReplace) => {
const { selections } = this.state;
if (R.contains(name, selections[group])) return null;
const pushState = isReplace ? '$set' : '$push';
this.setState(
prevState => ({
selections: update(prevState.selections, {
[group]: { [pushState]: [name] },
}),
}),
() => this.triggerAdvancedFilter()
);
};
You can apply your callback implementation in useEffect by giving your state variable in dependency array
You can refer to this also
How to use `setState` callback on react hooks

What causes this component to detect changes in its props?

In the following minimal example, a parent component has a data property and passes data.value to a child. I am struggling to understand what exactly is going on here with the update strategy:
const MY_DATAVALUE = {
a: 1,
b: 2
};
const DATA = {
value: MY_DATAVALUE
};
function Child(props) {
useEffect(() => {
console.log("child props changed");
}, [props]);
return <h1>Child</h1>;
}
export default function App() {
const [data, setData] = useState(DATA);
useEffect(() => {
console.log("data changed");
}, [data]);
useEffect(() => {
console.log("data.value changed");
}, [data.value]);
function handleButtonClick() {
const newData = {
value: MY_DATAVALUE
};
setData(newData);
}
return (
<>
<button onClick={handleButtonClick}>Button</button>
<Child value={data.value} />
</>
);
}
(See this Codesandbox)
Now, when the button is clicked, I think the following happens:
App's handleButtonClick() is executed and the data-state now refers to a new object. Therefore App's first useEffect (checking for data) triggers.
However data.value still contains the same reference (to MY_DATAVALUE), therefore App's second useEffect (checking for data.value) does not trigger.
BUT: The Child's useEffect (checking for props) triggers. Why is that? According to the parent, data.value did NOT change (Otherwise the second useEffect would have triggered).
Can you explain to me, why the Childs useEffect triggers`? How could I find out if the props "really" changed? Would I have to individually check for all prop-keys?
Thank you!
useEffect dependencies will trigger a change if what we provide is different. This happens if we pass the same reference with a different value or if we pass a different reference.
const newData = {
value: MY_DATAVALUE
};
setData(newData);
data now refers to a different object, while the value key refers to the same as the previous value.
This means, this hook will trigger:
useEffect(() => {
console.log("data changed");
}, [data]);
While this will not trigger:
useEffect(() => {
console.log("data.value changed");
}, [data.value]);
So far this is what you explained in both points.
In the case of the child, the props object is a new reference on every render.
For this reason, this hook will always trigger:
useEffect(() => {
console.log("child props changed");
}, [props]);
While this hook would not trigger:
const MY_DATAVALUE = {
a: 1,
b: 2
};
// In Parent...
<Child value={MY_DATAVALUE} />
// In Child...
useEffect(() => {
console.log("child value changed");
}, [props.value]);

Categories