What causes this component to detect changes in its props? - javascript

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]);

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

State not captured by callback in React component

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,
};

Wait for change of prop from parent component after changing it from a child in React

I have rewritten a Child class component in React to a functional component. Here is the simplified code example.
For sure, as so often, this is a simplified code and more things are done with the value in the parent component. That's why we have and need it there.
const Parent = (props) => {
const [value, setValue] = useState(null);
const handleChange = (newValue) => {
// do something with newValue and probably change it
// store the result in `newChangedValue`
setValue(newChangedValue);
}
return (
<Child value={value} onChange={handleChange}/>
);
}
const Child = (props) => {
const {value} = props;
// This solution does not work for me,
// because it's always triggered, when
// `value` changes. I only want to trigger
// `logValueFromProp` after clicking the
// Button.
useEffect(() => {
logValueFromProp();
}, [value]);
const handleClick = () => {
// some calculations to get `newValue`
// are happening here
props.onChange(newValue);
logValueFromProp();
}
const logValueFromProp = () {
console.log(prop.value);
}
return (
<Button onClick={handleClick} />
);
}
What I want to do is to log a properties value, but only if it got changed by clicking the button. So just using a useEffect does not work for me.
Before changing the child component to a functional component, the property had its new value before I was calling logValueFromProp(). Afterwards it doesn't. I guess that's cause of some timing, and I was just lucky that the property was updated before the function was called.
So the question is: How would you solve this situation? One solution I thought of was a state in the child component which I set when the button is clicked and in the useEffect I only call the function when the state is set and then reset the state. But that doesn't feel like the optimal solution to me...
Three possible solutions for you
Pass logValueFromProp the value directly — but in a comment you've explained that the value might be modified slightly by the parent component before being set on the child, which would make this not applicable.
Use a flag in a ref. But if the parent doesn't always change the prop, that would be unreliable.
Have the parent accept a callback in its handleChange.
#1
If possible, I'd pass the value directly to logValueFromProp when you want to log it. That's the simple, direct solution:
const Child = (props) => {
const {value} = props;
const handleClick = () => {
props.onChange(newValue);
logValueFromProp(newValue);
};
const logValueFromProp = (newValue = prop.value) {
console.log(newValue);
};
return (
<Button onClick={handleClick} />
);
};
But in a comment you've said the new value may not be exactly the same as what you called props.onChange with.
#2
You could use a ref to remember whether you want to log it when the component function is next called (which will presumably be after it changes):
const Child = (props) => {
const {value} = props;
const logValueRef = useRef(false);
if (logValueRef.current) {
logValueFromProp();
logValueRef.current = false;
}
const handleClick = () => {
props.onChange(newValue);
logValueRef.current = true;
};
const logValueFromProp = () {
console.log(prop.value);
};
return (
<Button onClick={handleClick} />
);
};
Using a ref instead of a state member means that when you clear the flag, it doesn't cause a re-render. (Your component function is only called after handleClick because the parent changes the value prop.)
Beware that if the parent component doesn't change the value when you call prop.onChange, the ref flag will remain set and then your component will mistakenly log the next changed value even if it isn't from the button. For that reason, it might make sense to try to move the logging to the parent, which knows how it responds to onChange.
#3
Given the issues with both of the above, the most robust solution would seem to be to modify Parent's handleChange so that it calls a callback with the possibly-modified value:
const Parent = (props) => {
const [value, setValue] = useState(null);
const handleChange = (newValue, callback) => {
// ^^^^^^^^^^−−−−−−−−−−−−−−−−− ***
// do something with newValue and probably change it
// store the result in `newChangedValue`
setValue(newChangedValue);
if (callback) { // ***
callback(newChangedValue); // ***
} // ***
};
return (
<Child value={value} onChange={handleChange}/>
);
};
const Child = (props) => {
const {value} = props;
const handleClick = () => {
props.onChange(newValue, logValueFromProp);
// ^^^^^^^^^^^^^^^^^^−−−−−−−−−−−−−− ***
}
const logValueFromProp = () {
console.log(prop.value);
};
return (
<Button onClick={handleClick} />
);
};
This answer is based upon the answer of T.J. Crowder (#2).
You can create a custom hook that accepts a callback and dependencies. And returns a function that will trigger a re-render (by using useState instead of useContext) calling the callback in the process.
I've enhanced his answer by allowing you to pass a dependency array which will be used to determine if the callback is called. If the dependency array is omitted, the callback is always called. When passed, the callback is only called if there was a change in the dependency array.
I went for the name useTrigger in the example below, but depending on preference you might like another name better. For example useChange.
const { useState, useCallback } = React;
const useTrigger = (function () {
function zip(a1, a2) {
return a1.map((_, i) => [a1[i], a2[i]]);
}
// compares 2 arrays assuming the length is the same
function equals(a1, a2) {
return zip(a1, a2).every(([e1, e2]) => Object.is(e1, e2));
}
return function (callback, deps) {
const [trigger, setTrigger] = useState(null);
if (trigger) {
if (!deps || !equals(deps, trigger.deps)) {
callback(...trigger.args);
}
setTrigger(null);
}
return useCallback((...args) => {
setTrigger({ args, deps });
}, deps);
}
})();
function Parent() {
const [value, setValue] = useState(null);
function handleChange(newValue) {
// Sometimes the value is changed, triggering `logValueFromProp()`.
// Sometimes it isn't.
if (Math.random() < 0.66) newValue++;
setValue(newValue);
}
return <Child value={value} onChange={handleChange} />;
}
function Child({ value, onChange }) {
const logValueFromProp = useTrigger(() => {
console.log(value);
}, [value]);
function handleClick() {
onChange(value || 0);
logValueFromProp();
}
return (
<button onClick={handleClick}>
Click Me!
</button>
);
}
ReactDOM.render(<Parent />, document.querySelector("#demo"));
<script crossorigin src="https://unpkg.com/react#17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#17/umd/react-dom.development.js"></script>
<div id="demo"></div>

Update the sibling component only

In the code below, how can I update only Component B and not Component A (or the Parent Component)
Component A:
const A = ({ data, clickCallback }) => {
console.debug('A');
return (<button onClick={clickCallback}>Component A</button>)
};
Component B:
const B = ({ filteredData }) => {
console.debug('B');
return <h1>Component B: {filteredData}</h1>;
};
Parent Component:
function Parent() {
console.debug('parent');
const [data, setData] = useState(0);
const handleClick = () => {
setData(data + 1);
};
return (
<div>
<A clickCallback={handleClick}/>
<B filteredData={data} />
</div>
);
}
So when clicking on the Component A button, I only see console.debug('B') in the console?
parent
A
B
B
B
...
Here is the link to the working code: https://codesandbox.io/s/musing-lehmann-kme8d?file=/src/index.js:233-245
NOTE: I have tried wrapping the handleClick inside a useCallback() but, still in the console I see:
parent
A
B
parent
A
B
...
With functional components, they are rerendered when their parent component rerenders. I.E. When Parent rerenders, then both A and B` will be rerendered in the "render phase" in order to compute a diff. This should not be confused with rendering to the DOM during the "commit phase".
You can wrap A in the memo Higher Order Component and pass a custom equality function that returns true/false if the previous and next props are equal.
memo
By default it will only shallowly compare complex objects in the props
object. If you want control over the comparison, you can also provide
a custom comparison function as the second argument.
function MyComponent(props) {
/* render using props */
}
function areEqual(prevProps, nextProps) {
/*
return true if passing nextProps to render would return
the same result as passing prevProps to render,
otherwise return false
*/
}
export default React.memo(MyComponent, areEqual);
I suggest also correctly console logging in an useEffect so you truly know when the component is rerendered to the DOM.
A functional state update should also be used to update the data state so the state value isn't closed over in the callback scope.
const A = ({ data, clickCallback }) => {
useEffect(() => {
console.debug("A");
});
return <button onClick={clickCallback}>Component A</button>;
};
const MemoA = memo(
A,
(prevProps, nextProps) => prevProps.data === nextProps.data
);
const B = ({ filteredData }) => {
useEffect(() => {
console.debug("B");
});
return <h1>Component B: {filteredData}</h1>;
};
function Parent() {
useEffect(() => {
console.debug("parent");
});
const [data, setData] = useState(0);
const handleClick = () => {
setData(data => data + 1); // <-- functional update
};
return (
<div>
<MemoA clickCallback={handleClick} />
<B filteredData={data} />
</div>
);
}

Why useEffect is not called, when props have changed, render is called?

I have useEffect and I don't understand why useEffect is not called, when props have changed, but render is called. I have something like this, 'block' is object:
const { block } = props;
useEffect(() => {
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
});
}, []);
Thank you for helping! Now, I have next question) My inputRef is null, when block is changed and render is called:
const { block } = props;
const inputRef = useRef(null);
useEffect(() => {
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
});
}, [block, inputRef]);
...
<SomeComponent
inputRef={inputRef}
/>
Because you have no parameters on your useEffect. If you pass the second parameter as an empty array, it will only fire when the component is mounted.
Here is a reference from the official docs
You can get this behavior by passing a block property as the second parameter in your useEffect function:
const { block } = props;
useEffect(() => {
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
});
}, [block]);
Don't forget that you can pass more properties:
useEffect(() => {}, [block, otherProperty])

Categories